Skip to content

ci(canon-quality): wire oddkit_audit on PR + push (Phase 3 PR-3.1, soft-block)#149

Open
klappy wants to merge 3 commits intomainfrom
phase-3/canon-quality-workflow-soft-block
Open

ci(canon-quality): wire oddkit_audit on PR + push (Phase 3 PR-3.1, soft-block)#149
klappy wants to merge 3 commits intomainfrom
phase-3/canon-quality-workflow-soft-block

Conversation

@klappy
Copy link
Copy Markdown
Owner

@klappy klappy commented Apr 27, 2026

Phase 3 PR-3.1 of the link-rot elimination campaign — wires oddkit_audit (live in prod since v0.26.0) into klappy.dev's CI as a soft-block quality gate. This is the first GitHub Actions workflow on the repo.## What this PR addsA single new file: .github/workflows/canon-quality.yml (~290 lines, well-commented).Triggers- Pull requests against main that touch writings/, canon/, odd/, docs/, or the workflow itself- Pushes to main with the same path filter- Manual workflow_dispatch with optional scope_paths inputBehavior1. POST a tools/call for oddkit_audit to https://oddkit.klappy.dev/mcp (default scope ["writings/"] per spec v2.2)2. Retry once on transient HTTP failure (handles the DNS/cold-start cases observed during PR #146 verification)3. Parse the SSE response, extract the action envelope, save as a workflow artifact (audit-response, 14-day retention)4. Render a sticky PR comment via marocchino/sticky-pull-request-comment@v2 (header: canon-quality-audit): - On status: OK — green checkmark, files-scanned count - On status: FINDINGS — table grouped by file (max 50 rendered, link to artifact for the rest), columns: line, rule, occurrence, message - On infrastructure failure — explicit "could not be checked" comment so the absence of signal is visible5. Write a workflow run summary with the same metrics6. Enforcement gate: read repo variable AUDIT_ENFORCEMENT_MODE (defaults to soft) - soft — never fails the job; the comment is the only signal - hard — fails the job when status != OKMode flip mechanismPR-3.1 ships in soft (no repo variable set → default). After the observation cycle (3–5 PRs through the gate), PR-3.2 will set vars.AUDIT_ENFORCEMENT_MODE to hard at https://github.com/klappy/klappy.dev/settings/variables/actions — single repo-config change, no workflow file edit needed.## Self-test on this PRThis PR's path filter includes .github/workflows/canon-quality.yml, so the workflow runs on this PR itself. Expected first-run signal: 3 dead-references in writings/ (the same 3 surfaced by post-deploy verification of v0.26.0 — actual link-rot in canon, real Phase 3 input). The job will not fail because mode is soft by default.## Refs- Spec: klappy://docs/oddkit/specs/oddkit-audit (DRAFT v2.2 — landing in companion canon PR #148)- Promote that made this possible: klappy/oddkit#146 (oddkit v0.26.0)- Session ledger: klappy://odd/ledger/2026-04-27-link-rot-phase-2-shipped (also in klappy.dev#148)- Resume handoff: klappy://odd/handoffs/2026-04-27-link-rot-phase-2-promote-resume (also in klappy.dev#148)- Campaign sequencing: klappy://docs/planning/link-rot-elimination-campaign## After merge1. Observation cycle — let 3–5 PRs run through the soft gate. Watch finding rate, sticky-comment readability, and CI duration.2. Fix surfaced rot — the 3 known dead references in writings/ are the obvious first sweep; line-level allowlist (<!-- audit-allow: dead-reference reason="..." -->) is available for genuinely-deferred targets like upcoming-but-unpublished articles.3. PR-3.2 — set vars.AUDIT_ENFORCEMENT_MODE to hard (no workflow file change required) once observation is satisfactory.## Notes- The workflow uses python3 - <<PY heredocs for JSON manipulation rather than installing jq — keeps the runner setup time near zero.- All comments and step-summaries are read-only; the workflow has pull-requests: write only for the sticky comment.- The soft default is the safe-by-construction state — even if this PR has a typo that makes AUDIT_ENFORCEMENT_MODE=hard accidentally read incorrectly, the worst case is reporting-only, not surprise PR blocks.


Note

Medium Risk
Adds a new CI workflow that calls an external production oddkit endpoint and posts sticky PR comments, introducing a new dependency that can affect developer workflow (and optionally fail builds when flipped to hard mode). Permissions include pull-requests: write, so misconfiguration could spam or overwrite PR comments.

Overview
Introduces a new GitHub Actions workflow (canon-quality.yml) that runs oddkit_audit on PRs/pushes touching canon content (and via manual dispatch), uploads the JSON audit result as an artifact, and posts a sticky PR comment summarizing findings.

Adds a configurable enforcement gate via AUDIT_ENFORCEMENT_MODE (default soft), with retry/parse handling for audit infrastructure failures and an optional hard mode that fails the job only when the audit status is FINDINGS (not PARTIAL_INDEX).

Reviewed by Cursor Bugbot for commit 81c0aa2. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 27, 2026

Canon Quality — oddkit_audit ⚠️

3 finding(s) in writings/ (39 files scanned). Mode: soft.

writings/choosing-faith-not-fear.md — 1 finding(s)
Line Rule Occurrence Message
203 dead-reference klappy://writings/four-questions-that-change-everything URI does not resolve
writings/the-broken-wall-and-the-buried-talent.md — 1 finding(s)
Line Rule Occurrence Message
332 dead-reference klappy://draft-zeros/appendix-a-the-biblical-roots URI does not resolve
writings/the-voice-came-first.md — 1 finding(s)
Line Rule Occurrence Message
244 dead-reference klappy://writings/four-questions-that-change-everything URI does not resolve

Soft-block mode — this status is informational. The job will not fail. Hard-block ships in PR-3.2 after the observation cycle.

Spec: klappy://docs/oddkit/specs/oddkit-audit · Workflow: .github/workflows/canon-quality.yml · Run: #3

Comment thread .github/workflows/canon-quality.yml
Comment thread .github/workflows/canon-quality.yml
…X from hard block

- Wrap the SSE/JSON-RPC parsing heredoc with an if-guard so a parse error
  routes through the same audit_failed=true path used for HTTP failures
  instead of letting bash -e abort the step (which previously skipped the
  PR comment and produced a red CI check with zero feedback in soft mode).
- Enforcement gate now only fails on STATUS=FINDINGS in hard mode. Per
  docs/oddkit/specs/oddkit-audit.md, PARTIAL_INDEX is best-effort and
  must remain non-blocking (warning, retry on next push).
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Hard-mode comment misleads on PARTIAL_INDEX status
    • The renderer now mirrors the enforcement gate's predicate (will_fail = mode=='hard' and status=='FINDINGS'), picks the warning icon and a PARTIAL_INDEX-specific footer in hard mode so the comment matches the actual CI outcome.
  • ✅ Fixed: Workflow input interpolated unsafely into shell
    • Replaced direct ${{ github.event.inputs.scope_paths }} interpolation in the run script with an INPUT_SCOPE_PATHS env var referenced as a shell variable, treating the input as data and removing the quoting/injection hazard.
Preview (81c0aa2b13)
diff --git a/.github/workflows/canon-quality.yml b/.github/workflows/canon-quality.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/canon-quality.yml
@@ -1,0 +1,314 @@
+name: Canon Quality
+
+# Calls oddkit_audit against prod and reports/blocks based on findings.
+# Spec: klappy://docs/oddkit/specs/oddkit-audit (DRAFT v2.2)
+# Mode is controlled by repo variable AUDIT_ENFORCEMENT_MODE:
+#   - 'soft' (default): comment with findings, never fail the job
+#   - 'hard': fail the job when status != OK
+# Set the variable at https://github.com/klappy/klappy.dev/settings/variables/actions
+# PR-3.1 ships in soft. PR-3.2 will flip to hard after the observation cycle.
+
+on:
+  pull_request:
+    branches: [main]
+    paths:
+      - 'writings/**'
+      - 'canon/**'
+      - 'odd/**'
+      - 'docs/**'
+      - '.github/workflows/canon-quality.yml'
+  push:
+    branches: [main]
+    paths:
+      - 'writings/**'
+      - 'canon/**'
+      - 'odd/**'
+      - 'docs/**'
+      - '.github/workflows/canon-quality.yml'
+  workflow_dispatch:
+    inputs:
+      scope_paths:
+        description: 'JSON array of scope paths (default ["writings/"])'
+        required: false
+        default: '["writings/"]'
+
+permissions:
+  contents: read
+  pull-requests: write
+
+env:
+  ODDKIT_MCP_URL: https://oddkit.klappy.dev/mcp
+  ENFORCEMENT_MODE: ${{ vars.AUDIT_ENFORCEMENT_MODE || 'soft' }}
+  USER_AGENT: 'klappy.dev-canon-quality/1.0 (+github-actions; ${{ github.repository }}#${{ github.run_id }})'
+
+jobs:
+  audit:
+    name: Reference integrity audit
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      - name: Resolve scope
+        id: scope
+        env:
+          INPUT_SCOPE_PATHS: ${{ github.event.inputs.scope_paths }}
+        run: |
+          if [ -n "$INPUT_SCOPE_PATHS" ]; then
+            PATHS="$INPUT_SCOPE_PATHS"
+          else
+            PATHS='["writings/"]'
+          fi
+          echo "Scope paths: $PATHS"
+          echo "paths=$PATHS" >> "$GITHUB_OUTPUT"
+
+      - name: Call oddkit_audit (with one retry on transient failure)
+        id: audit
+        env:
+          SCOPE_PATHS: ${{ steps.scope.outputs.paths }}
+        run: |
+          set -uo pipefail
+
+          # Build JSON-RPC payload. The 'input' arg is a JSON string per the action's schema.
+          INNER=$(python3 -c "import json,os; print(json.dumps({'scope':{'paths':json.loads(os.environ['SCOPE_PATHS'])}}))")
+          PAYLOAD=$(python3 -c "
+          import json, sys
+          inner = sys.argv[1]
+          print(json.dumps({
+            'jsonrpc': '2.0',
+            'id': 1,
+            'method': 'tools/call',
+            'params': {'name': 'oddkit_audit', 'arguments': {'input': inner}}
+          }))
+          " "$INNER")
+          echo "$PAYLOAD" > /tmp/req.json
+
+          AUDIT_OK=false
+          HTTP_CODE=000
+          for attempt in 1 2; do
+            HTTP_CODE=$(curl -sS -o /tmp/resp.raw -w '%{http_code}' \
+              -X POST \
+              -H 'Content-Type: application/json' \
+              -H 'Accept: application/json, text/event-stream' \
+              -H "User-Agent: $USER_AGENT" \
+              --max-time 120 \
+              -d @/tmp/req.json \
+              "$ODDKIT_MCP_URL" || echo 000)
+            echo "Attempt $attempt -> HTTP $HTTP_CODE"
+            if [ "$HTTP_CODE" = "200" ]; then
+              AUDIT_OK=true
+              break
+            fi
+            if [ "$attempt" -lt 2 ]; then
+              echo "Transient failure; sleeping 8s before retry"
+              sleep 8
+            fi
+          done
+
+          if [ "$AUDIT_OK" != "true" ]; then
+            echo "Audit could not be completed after 2 attempts."
+            echo "Response (first 500 bytes):"
+            head -c 500 /tmp/resp.raw || true
+            echo ""
+            echo "audit_failed=true" >> "$GITHUB_OUTPUT"
+            echo "http_code=$HTTP_CODE" >> "$GITHUB_OUTPUT"
+            exit 0   # don't fail the step; the report step will surface this
+          fi
+
+          # Parse the SSE response. The MCP server returns event-stream with a final
+          # 'data: { ... }' line containing the JSON-RPC envelope.
+          # Guard with `if !` so any parse failure (sys.exit / unhandled exception)
+          # is routed through the same audit_failed=true path used for HTTP failures,
+          # rather than aborting the step under bash -e and skipping the comment steps.
+          if ! python3 - <<'PY'
+          import json, sys
+          with open('/tmp/resp.raw') as f:
+              raw = f.read()
+          data_lines = [l[6:] for l in raw.splitlines() if l.startswith('data: ')]
+          if not data_lines:
+              sys.stderr.write("No SSE 'data: ' lines in response\n")
+              sys.stderr.write(raw[:500])
+              sys.exit(1)
+          envelope = json.loads(data_lines[-1])
+          if 'error' in envelope:
+              sys.stderr.write(f"JSON-RPC error: {envelope['error']}\n")
+              sys.exit(1)
+          # Extract the action envelope from content[0].text
+          content = envelope.get('result', {}).get('content', [])
+          texts = [c.get('text','') for c in content if c.get('type') == 'text']
+          if not texts:
+              sys.stderr.write("No text content in tool-call result\n")
+              sys.exit(1)
+          inner = json.loads(texts[0])
+          with open('/tmp/audit-result.json','w') as f:
+              json.dump(inner, f, indent=2)
+          # Surface a tiny diagnostic to logs
+          r = inner.get('result', {})
+          print(f"status={r.get('status')} findings={r.get('summary',{}).get('total_findings',0)} files={r.get('summary',{}).get('files_scanned',0)}")
+          PY
+          then
+            echo "Failed to parse audit response."
+            echo "Response (first 500 bytes):"
+            head -c 500 /tmp/resp.raw || true
+            echo ""
+            echo "audit_failed=true" >> "$GITHUB_OUTPUT"
+            echo "http_code=parse_error" >> "$GITHUB_OUTPUT"
+            exit 0   # don't fail the step; the report step will surface this
+          fi
+
+          echo "audit_failed=false" >> "$GITHUB_OUTPUT"
+
+      - name: Upload audit response artifact
+        if: always() && steps.audit.outputs.audit_failed == 'false'
+        uses: actions/upload-artifact@v4
+        with:
+          name: audit-response
+          path: /tmp/audit-result.json
+          retention-days: 14
+          if-no-files-found: warn
+
+      - name: Render comment body
+        id: render
+        if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed != 'true'
+        env:
+          MODE: ${{ env.ENFORCEMENT_MODE }}
+        run: |
+          python3 - <<'PY'
+          import json, os
+
+          with open('/tmp/audit-result.json') as f:
+              d = json.load(f)
+          r = d.get('result', {})
+          status = r.get('status', '?')
+          summary = r.get('summary', {})
+          findings = r.get('findings', [])
+          suppressed = r.get('suppressed_findings', [])
+          scope = r.get('scope', {})
+          mode = os.environ.get('MODE', 'soft')
+          paths_label = ', '.join(scope.get('paths', []))
+
+          lines = []
+          if status == 'OK':
+              lines.append('### Canon Quality — `oddkit_audit` ✅')
+              lines.append('')
+              lines.append(f'No dead `klappy://` references or legacy link patterns found in `{paths_label}`. {summary.get("files_scanned", 0)} files scanned.')
+          else:
+              # Per the audit spec, PARTIAL_INDEX is non-blocking even in hard mode
+              # (best-effort findings, retry on next push). Only FINDINGS fails in hard.
+              will_fail = mode == 'hard' and status == 'FINDINGS'
+              icon = '❌' if will_fail else '⚠️'
+              lines.append(f'### Canon Quality — `oddkit_audit` {icon}')
+              lines.append('')
+              total = summary.get('total_findings', len(findings))
+              lines.append(f'**{total} finding(s)** in `{paths_label}` ({summary.get("files_scanned", 0)} files scanned). Mode: `{mode}`.')
+              lines.append('')
+
+              # Group by file, cap at 50 rendered to keep PR comment manageable
+              CAP = 50
+              by_file = {}
+              for f in findings[:CAP]:
+                  loc = f.get('location', {})
+                  by_file.setdefault(loc.get('path', '?'), []).append(
+                      (loc.get('line'), f.get('rule_id'), f.get('occurrence'), f.get('message'))
+                  )
+
+              for path, items in sorted(by_file.items()):
+                  lines.append(f'<details><summary><code>{path}</code> — {len(items)} finding(s)</summary>')
+                  lines.append('')
+                  lines.append('| Line | Rule | Occurrence | Message |')
+                  lines.append('|---:|---|---|---|')
+                  for line, rule, occ, msg in items:
+                      occ_safe = (occ or '').replace('|', '\\|')
+                      msg_safe = (msg or '').replace('|', '\\|')
+                      lines.append(f'| {line} | `{rule}` | `{occ_safe}` | {msg_safe} |')
+                  lines.append('')
+                  lines.append('</details>')
+
+              if len(findings) > CAP:
+                  lines.append('')
+                  lines.append(f'_… and {len(findings) - CAP} more finding(s). See the `audit-response` workflow artifact for the full list._')
+
+              if suppressed:
+                  s_count = len(suppressed) if isinstance(suppressed, list) else suppressed
+                  lines.append('')
+                  lines.append(f'_{s_count} finding(s) suppressed via `<!-- audit-allow: ... -->` directives._')
+
+              lines.append('')
+              if mode == 'soft':
+                  lines.append('> **Soft-block mode** — this status is informational. The job will not fail. Hard-block ships in PR-3.2 after the observation cycle.')
+              elif status == 'PARTIAL_INDEX':
+                  lines.append('> **Hard-block mode** — `PARTIAL_INDEX` is non-blocking per the audit spec (best-effort findings, retry on next push). The job will not fail on this status.')
+              else:
+                  lines.append('> **Hard-block mode** — this PR will fail until findings are resolved. Fix the dead references or add a line-level allowlist directive (`<!-- audit-allow: dead-reference reason="..." -->`) above the offending link.')
+
+          lines.append('')
+          lines.append('<sub>Spec: `klappy://docs/oddkit/specs/oddkit-audit` · Workflow: `.github/workflows/canon-quality.yml` · Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})</sub>')
+
+          with open('/tmp/comment.md', 'w') as f:
+              f.write('\n'.join(lines))
+          PY
+
+      - name: Sticky comment — audit results
+        if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed != 'true'
+        uses: marocchino/sticky-pull-request-comment@v2
+        with:
+          header: canon-quality-audit
+          path: /tmp/comment.md
+
+      - name: Sticky comment — audit infrastructure failure
+        if: github.event_name == 'pull_request' && steps.audit.outputs.audit_failed == 'true'
+        uses: marocchino/sticky-pull-request-comment@v2
+        with:
+          header: canon-quality-audit
+          message: |
+            ### Canon Quality — `oddkit_audit` ⚠️ (infrastructure)
+
+            The audit could not be completed (HTTP `${{ steps.audit.outputs.http_code }}` from `${{ env.ODDKIT_MCP_URL }}` after retry). This run produces no signal about reference integrity — re-run the workflow if the failure was transient.
+
+            The job does not fail on infrastructure issues; persistent failures should be tracked separately.
+
+            <sub>Workflow: `.github/workflows/canon-quality.yml` · Run: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})</sub>
+
+      - name: Workflow step summary
+        if: always()
+        run: |
+          {
+            echo "## Canon Quality — \`oddkit_audit\`"
+            echo ""
+            echo "- **Mode**: \`$ENFORCEMENT_MODE\`"
+            echo "- **Endpoint**: \`$ODDKIT_MCP_URL\`"
+          } >> "$GITHUB_STEP_SUMMARY"
+
+          if [ "${{ steps.audit.outputs.audit_failed }}" = "true" ]; then
+            echo "- **Result**: infrastructure failure (HTTP \`${{ steps.audit.outputs.http_code }}\`)" >> "$GITHUB_STEP_SUMMARY"
+          elif [ -f /tmp/audit-result.json ]; then
+            python3 - <<'PY'
+          import json, os
+          with open('/tmp/audit-result.json') as f:
+              d = json.load(f)
+          r = d.get('result', {})
+          s = r.get('summary', {})
+          out_path = os.environ['GITHUB_STEP_SUMMARY']
+          with open(out_path, 'a') as f:
+              f.write(f"- **Status**: `{r.get('status')}`\n")
+              f.write(f"- **Total findings**: {s.get('total_findings', 0)}\n")
+              f.write(f"- **By severity**: `{s.get('by_severity', {})}`\n")
+              f.write(f"- **Files scanned**: {s.get('files_scanned', 0)}\n")
+              f.write(f"- **Scope**: `{r.get('scope', {})}`\n")
+              if s.get('truncated'):
+                  f.write(f"- **Truncated**: yes (response was capped)\n")
+          PY
+          else
+            echo "- **Result**: no audit result file produced" >> "$GITHUB_STEP_SUMMARY"
+          fi
+
+      - name: Enforcement gate
+        if: steps.audit.outputs.audit_failed != 'true'
+        run: |
+          STATUS=$(python3 -c "import json; print(json.load(open('/tmp/audit-result.json'))['result']['status'])")
+          echo "Enforcement mode: $ENFORCEMENT_MODE | Audit status: $STATUS"
+          # Per the audit spec, PARTIAL_INDEX is non-blocking (best-effort findings,
+          # retry on next push). Only FINDINGS should fail the job in hard mode.
+          if [ "$ENFORCEMENT_MODE" = "hard" ] && [ "$STATUS" = "FINDINGS" ]; then
+            echo "::error::Hard-block mode: oddkit_audit returned status=$STATUS. Failing job — fix the dead references or add explicit allowlist directives."
+            exit 1
+          fi
+          echo "Not failing the job (mode=$ENFORCEMENT_MODE, status=$STATUS)."

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 2d9d09a. Configure here.

Comment thread .github/workflows/canon-quality.yml
Comment thread .github/workflows/canon-quality.yml
… scope input

- Render comment now treats PARTIAL_INDEX as non-blocking even in hard mode, matching the enforcement gate which only fails on FINDINGS. Adds a PARTIAL_INDEX-specific footer in hard mode and selects the warning icon when the job will not fail.
- Resolve scope step passes github.event.inputs.scope_paths via env (INPUT_SCOPE_PATHS) instead of interpolating into the shell body, eliminating the quoting hazard and shell injection vector under workflow_dispatch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants