|
| 1 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +# |
| 4 | +# dogfood-gate.yml — Hyperpolymath Dogfooding Quality Gate |
| 5 | +# Validates that the repo uses hyperpolymath's own formats and tools. |
| 6 | +# Companion to static-analysis-gate.yml (security) — this is for format compliance. |
| 7 | +name: Dogfood Gate |
| 8 | + |
| 9 | +on: |
| 10 | + pull_request: |
| 11 | + branches: ['**'] |
| 12 | + push: |
| 13 | + branches: [main, master] |
| 14 | + |
| 15 | +permissions: |
| 16 | + contents: read |
| 17 | + |
| 18 | +jobs: |
| 19 | + # --------------------------------------------------------------------------- |
| 20 | + # Job 1: A2ML manifest validation |
| 21 | + # --------------------------------------------------------------------------- |
| 22 | + a2ml-validate: |
| 23 | + name: Validate A2ML manifests |
| 24 | + runs-on: ubuntu-latest |
| 25 | + |
| 26 | + steps: |
| 27 | + - name: Checkout repository |
| 28 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 29 | + |
| 30 | + - name: Check for A2ML files |
| 31 | + id: detect |
| 32 | + run: | |
| 33 | + COUNT=$(find . -name '*.a2ml' -not -path './.git/*' | wc -l) |
| 34 | + echo "count=$COUNT" >> "$GITHUB_OUTPUT" |
| 35 | + if [ "$COUNT" -eq 0 ]; then |
| 36 | + echo "::warning::No .a2ml manifest files found. Every RSR repo should have 0-AI-MANIFEST.a2ml" |
| 37 | + fi |
| 38 | +
|
| 39 | + - name: Validate A2ML manifests |
| 40 | + if: steps.detect.outputs.count > 0 |
| 41 | + uses: hyperpolymath/a2ml-validate-action@main |
| 42 | + with: |
| 43 | + path: '.' |
| 44 | + strict: 'false' |
| 45 | + |
| 46 | + - name: Write summary |
| 47 | + run: | |
| 48 | + A2ML_COUNT="${{ steps.detect.outputs.count }}" |
| 49 | + if [ "$A2ML_COUNT" -eq 0 ]; then |
| 50 | + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" |
| 51 | + ## A2ML Validation |
| 52 | +
|
| 53 | + :warning: **No .a2ml files found.** Every RSR-compliant repo should have at least `0-AI-MANIFEST.a2ml`. |
| 54 | +
|
| 55 | + Create one with: `a2mliser init` or copy from [rsr-template-repo](https://github.com/hyperpolymath/rsr-template-repo). |
| 56 | + EOF |
| 57 | + else |
| 58 | + echo "## A2ML Validation" >> "$GITHUB_STEP_SUMMARY" |
| 59 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 60 | + echo "Scanned **${A2ML_COUNT}** .a2ml file(s). See step output for details." >> "$GITHUB_STEP_SUMMARY" |
| 61 | + fi |
| 62 | +
|
| 63 | + # --------------------------------------------------------------------------- |
| 64 | + # Job 2: K9 contract validation |
| 65 | + # --------------------------------------------------------------------------- |
| 66 | + k9-validate: |
| 67 | + name: Validate K9 contracts |
| 68 | + runs-on: ubuntu-latest |
| 69 | + |
| 70 | + steps: |
| 71 | + - name: Checkout repository |
| 72 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 73 | + |
| 74 | + - name: Check for K9 files |
| 75 | + id: detect |
| 76 | + run: | |
| 77 | + COUNT=$(find . \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path './.git/*' | wc -l) |
| 78 | + CONFIG_COUNT=$(find . \( -name '*.toml' -o -name '*.yaml' -o -name '*.yml' -o -name '*.json' \) \ |
| 79 | + -not -path './.git/*' -not -path './node_modules/*' -not -path './.deno/*' \ |
| 80 | + -not -name 'package-lock.json' -not -name 'Cargo.lock' -not -name 'deno.lock' | wc -l) |
| 81 | + echo "k9_count=$COUNT" >> "$GITHUB_OUTPUT" |
| 82 | + echo "config_count=$CONFIG_COUNT" >> "$GITHUB_OUTPUT" |
| 83 | + if [ "$COUNT" -eq 0 ] && [ "$CONFIG_COUNT" -gt 0 ]; then |
| 84 | + echo "::warning::Found $CONFIG_COUNT config files but no K9 contracts. Run k9iser to generate contracts." |
| 85 | + fi |
| 86 | +
|
| 87 | + - name: Validate K9 contracts |
| 88 | + if: steps.detect.outputs.k9_count > 0 |
| 89 | + uses: hyperpolymath/k9-validate-action@main |
| 90 | + with: |
| 91 | + path: '.' |
| 92 | + strict: 'false' |
| 93 | + |
| 94 | + - name: Write summary |
| 95 | + run: | |
| 96 | + K9_COUNT="${{ steps.detect.outputs.k9_count }}" |
| 97 | + CFG_COUNT="${{ steps.detect.outputs.config_count }}" |
| 98 | + if [ "$K9_COUNT" -eq 0 ]; then |
| 99 | + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" |
| 100 | + ## K9 Contract Validation |
| 101 | +
|
| 102 | + :warning: **No K9 contract files found.** Repos with configuration files should have K9 contracts. |
| 103 | +
|
| 104 | + Generate contracts with: `k9iser generate .` |
| 105 | + EOF |
| 106 | + else |
| 107 | + echo "## K9 Contract Validation" >> "$GITHUB_STEP_SUMMARY" |
| 108 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 109 | + echo "Validated **${K9_COUNT}** K9 contract(s) against **${CFG_COUNT}** config file(s)." >> "$GITHUB_STEP_SUMMARY" |
| 110 | + fi |
| 111 | +
|
| 112 | + # --------------------------------------------------------------------------- |
| 113 | + # Job 3: Empty-linter — invisible character detection |
| 114 | + # --------------------------------------------------------------------------- |
| 115 | + empty-lint: |
| 116 | + name: Empty-linter (invisible characters) |
| 117 | + runs-on: ubuntu-latest |
| 118 | + |
| 119 | + steps: |
| 120 | + - name: Checkout repository |
| 121 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 122 | + |
| 123 | + - name: Scan for invisible characters |
| 124 | + id: lint |
| 125 | + run: | |
| 126 | + # Inline invisible character detection (from empty-linter's core patterns). |
| 127 | + # Checks for: zero-width spaces, zero-width joiners, BOM, soft hyphens, |
| 128 | + # non-breaking spaces, null bytes, and other invisible Unicode in source files. |
| 129 | + set +e |
| 130 | + PATTERNS='\xc2\xa0|\xe2\x80\x8b|\xe2\x80\x8c|\xe2\x80\x8d|\xef\xbb\xbf|\xc2\xad|\xe2\x80\x8e|\xe2\x80\x8f|\xe2\x80\xaa|\xe2\x80\xab|\xe2\x80\xac|\xe2\x80\xad|\xe2\x80\xae|\x00' |
| 131 | + find "$GITHUB_WORKSPACE" \ |
| 132 | + -not -path '*/.git/*' -not -path '*/node_modules/*' \ |
| 133 | + -not -path '*/.deno/*' -not -path '*/target/*' \ |
| 134 | + -not -path '*/_build/*' -not -path '*/deps/*' \ |
| 135 | + -not -path '*/external_corpora/*' -not -path '*/.lake/*' \ |
| 136 | + -type f \( -name '*.rs' -o -name '*.ex' -o -name '*.exs' -o -name '*.res' \ |
| 137 | + -o -name '*.js' -o -name '*.ts' -o -name '*.json' -o -name '*.toml' \ |
| 138 | + -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' -o -name '*.adoc' \ |
| 139 | + -o -name '*.idr' -o -name '*.zig' -o -name '*.v' -o -name '*.jl' \ |
| 140 | + -o -name '*.gleam' -o -name '*.hs' -o -name '*.ml' -o -name '*.sh' \) \ |
| 141 | + -exec grep -Prl "$PATTERNS" {} \; > /tmp/empty-lint-results.txt 2>/dev/null |
| 142 | + EL_EXIT=$? |
| 143 | + set -e |
| 144 | +
|
| 145 | + FINDINGS=$(wc -l < /tmp/empty-lint-results.txt 2>/dev/null || echo 0) |
| 146 | + echo "findings=$FINDINGS" >> "$GITHUB_OUTPUT" |
| 147 | + echo "exit_code=$EL_EXIT" >> "$GITHUB_OUTPUT" |
| 148 | + echo "ready=true" >> "$GITHUB_OUTPUT" |
| 149 | +
|
| 150 | + # Emit annotations for each file with invisible chars |
| 151 | + while IFS= read -r filepath; do |
| 152 | + [ -z "$filepath" ] && continue |
| 153 | + REL_PATH="${filepath#$GITHUB_WORKSPACE/}" |
| 154 | + echo "::warning file=${REL_PATH}::Invisible Unicode characters detected (zero-width space, BOM, NBSP, etc.)" |
| 155 | + done < /tmp/empty-lint-results.txt |
| 156 | +
|
| 157 | + - name: Write summary |
| 158 | + run: | |
| 159 | + if [ "${{ steps.lint.outputs.ready }}" = "true" ]; then |
| 160 | + FINDINGS="${{ steps.lint.outputs.findings }}" |
| 161 | + if [ "$FINDINGS" -gt 0 ] 2>/dev/null; then |
| 162 | + echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY" |
| 163 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 164 | + echo "Found **${FINDINGS}** invisible character issue(s). See annotations above." >> "$GITHUB_STEP_SUMMARY" |
| 165 | + else |
| 166 | + echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY" |
| 167 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 168 | + echo ":white_check_mark: No invisible character issues found." >> "$GITHUB_STEP_SUMMARY" |
| 169 | + fi |
| 170 | + else |
| 171 | + echo "## Empty-Linter" >> "$GITHUB_STEP_SUMMARY" |
| 172 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 173 | + echo "Skipped: empty-linter not available." >> "$GITHUB_STEP_SUMMARY" |
| 174 | + fi |
| 175 | +
|
| 176 | + # --------------------------------------------------------------------------- |
| 177 | + # Job 4: Groove manifest check (for repos that should expose services) |
| 178 | + # --------------------------------------------------------------------------- |
| 179 | + groove-check: |
| 180 | + name: Groove manifest check |
| 181 | + runs-on: ubuntu-latest |
| 182 | + |
| 183 | + steps: |
| 184 | + - name: Checkout repository |
| 185 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 186 | + |
| 187 | + - name: Check for Groove manifest |
| 188 | + id: groove |
| 189 | + run: | |
| 190 | + # Check for static or dynamic Groove endpoints |
| 191 | + HAS_MANIFEST="false" |
| 192 | + HAS_GROOVE_CODE="false" |
| 193 | +
|
| 194 | + if [ -f ".well-known/groove/manifest.json" ]; then |
| 195 | + HAS_MANIFEST="true" |
| 196 | + # Validate the manifest JSON |
| 197 | + if ! jq empty .well-known/groove/manifest.json 2>/dev/null; then |
| 198 | + echo "::error file=.well-known/groove/manifest.json::Invalid JSON in Groove manifest" |
| 199 | + else |
| 200 | + SVC_ID=$(jq -r '.service_id // "unknown"' .well-known/groove/manifest.json) |
| 201 | + echo "service_id=$SVC_ID" >> "$GITHUB_OUTPUT" |
| 202 | + fi |
| 203 | + fi |
| 204 | +
|
| 205 | + # Check for Groove endpoint code (Rust, Elixir, Zig, V) |
| 206 | + if grep -rl 'well-known/groove' --include='*.rs' --include='*.ex' --include='*.zig' --include='*.v' --include='*.res' . 2>/dev/null | head -1 | grep -q .; then |
| 207 | + HAS_GROOVE_CODE="true" |
| 208 | + fi |
| 209 | +
|
| 210 | + # Check if this repo likely serves HTTP (has server/listener code) |
| 211 | + HAS_SERVER="false" |
| 212 | + 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 |
| 213 | + HAS_SERVER="true" |
| 214 | + fi |
| 215 | +
|
| 216 | + echo "has_manifest=$HAS_MANIFEST" >> "$GITHUB_OUTPUT" |
| 217 | + echo "has_groove_code=$HAS_GROOVE_CODE" >> "$GITHUB_OUTPUT" |
| 218 | + echo "has_server=$HAS_SERVER" >> "$GITHUB_OUTPUT" |
| 219 | +
|
| 220 | + if [ "$HAS_SERVER" = "true" ] && [ "$HAS_MANIFEST" = "false" ] && [ "$HAS_GROOVE_CODE" = "false" ]; then |
| 221 | + echo "::warning::This repo has server code but no Groove endpoint. Add .well-known/groove/manifest.json for service discovery." |
| 222 | + fi |
| 223 | +
|
| 224 | + - name: Write summary |
| 225 | + run: | |
| 226 | + echo "## Groove Protocol Check" >> "$GITHUB_STEP_SUMMARY" |
| 227 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 228 | + echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY" |
| 229 | + echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" |
| 230 | + echo "| Static manifest (.well-known/groove/manifest.json) | ${{ steps.groove.outputs.has_manifest }} |" >> "$GITHUB_STEP_SUMMARY" |
| 231 | + echo "| Groove endpoint in code | ${{ steps.groove.outputs.has_groove_code }} |" >> "$GITHUB_STEP_SUMMARY" |
| 232 | + echo "| Has HTTP server code | ${{ steps.groove.outputs.has_server }} |" >> "$GITHUB_STEP_SUMMARY" |
| 233 | +
|
| 234 | + # --------------------------------------------------------------------------- |
| 235 | + # Job 5: Dogfooding summary |
| 236 | + # --------------------------------------------------------------------------- |
| 237 | + dogfood-summary: |
| 238 | + name: Dogfooding compliance summary |
| 239 | + runs-on: ubuntu-latest |
| 240 | + needs: [a2ml-validate, k9-validate, empty-lint, groove-check] |
| 241 | + if: always() |
| 242 | + |
| 243 | + steps: |
| 244 | + - name: Checkout repository |
| 245 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 246 | + |
| 247 | + - name: Generate dogfooding scorecard |
| 248 | + run: | |
| 249 | + SCORE=0 |
| 250 | + MAX=5 |
| 251 | +
|
| 252 | + # A2ML manifest present? |
| 253 | + if find . -name '*.a2ml' -not -path './.git/*' | head -1 | grep -q .; then |
| 254 | + SCORE=$((SCORE + 1)) |
| 255 | + A2ML_STATUS=":white_check_mark:" |
| 256 | + else |
| 257 | + A2ML_STATUS=":x:" |
| 258 | + fi |
| 259 | +
|
| 260 | + # K9 contracts present? |
| 261 | + if find . \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path './.git/*' | head -1 | grep -q .; then |
| 262 | + SCORE=$((SCORE + 1)) |
| 263 | + K9_STATUS=":white_check_mark:" |
| 264 | + else |
| 265 | + K9_STATUS=":x:" |
| 266 | + fi |
| 267 | +
|
| 268 | + # .editorconfig present? |
| 269 | + if [ -f ".editorconfig" ]; then |
| 270 | + SCORE=$((SCORE + 1)) |
| 271 | + EC_STATUS=":white_check_mark:" |
| 272 | + else |
| 273 | + EC_STATUS=":x:" |
| 274 | + fi |
| 275 | +
|
| 276 | + # Groove manifest or code? |
| 277 | + 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 |
| 278 | + SCORE=$((SCORE + 1)) |
| 279 | + GROOVE_STATUS=":white_check_mark:" |
| 280 | + else |
| 281 | + GROOVE_STATUS=":ballot_box_with_check:" |
| 282 | + fi |
| 283 | +
|
| 284 | + # VeriSimDB integration? |
| 285 | + 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 |
| 286 | + SCORE=$((SCORE + 1)) |
| 287 | + VSDB_STATUS=":white_check_mark:" |
| 288 | + else |
| 289 | + VSDB_STATUS=":ballot_box_with_check:" |
| 290 | + fi |
| 291 | +
|
| 292 | + cat <<EOF >> "$GITHUB_STEP_SUMMARY" |
| 293 | + ## Dogfooding Scorecard |
| 294 | +
|
| 295 | + **Score: ${SCORE}/${MAX}** |
| 296 | +
|
| 297 | + | Tool/Format | Status | Notes | |
| 298 | + |-------------|--------|-------| |
| 299 | + | A2ML manifest (0-AI-MANIFEST.a2ml) | ${A2ML_STATUS} | Required for all RSR repos | |
| 300 | + | K9 contracts | ${K9_STATUS} | Required for repos with config files | |
| 301 | + | .editorconfig | ${EC_STATUS} | Required for all repos | |
| 302 | + | Groove endpoint | ${GROOVE_STATUS} | Required for service repos | |
| 303 | + | VeriSimDB integration | ${VSDB_STATUS} | Required for stateful repos | |
| 304 | +
|
| 305 | + --- |
| 306 | + *Generated by the [Dogfood Gate](https://github.com/hyperpolymath/rsr-template-repo) workflow.* |
| 307 | + *Dogfooding is guinea pig fooding — we test our tools on ourselves.* |
| 308 | + EOF |
0 commit comments