From 84cd3d5f933dd496bca7f976526668df737344f5 Mon Sep 17 00:00:00 2001 From: Cobus Greyling Date: Thu, 11 Jun 2026 15:08:20 +0200 Subject: [PATCH] fix(ci): unblock daily-triage PR merge and append run log Daily-triage PRs were blocked because GITHUB_TOKEN pushes do not trigger pull_request workflows, so required validate/audit checks never ran. - Run validate and audit gates inline before opening the PR - Post success commit statuses (validate, audit) on the PR head SHA - Append loop-run-log.md each run with 30-day pruning - Extract shared ci-validate-gates.sh and ci-audit-gates.sh for reuse --- .github/workflows/audit.yml | 44 +---------- .github/workflows/daily-triage.yml | 98 ++++++++++++++++++++++--- .github/workflows/validate-patterns.yml | 47 +----------- LOOP.md | 2 +- scripts/append-run-log.mjs | 48 ++++++++++++ scripts/ci-audit-gates.sh | 41 +++++++++++ scripts/ci-validate-gates.sh | 36 +++++++++ 7 files changed, 218 insertions(+), 98 deletions(-) create mode 100644 scripts/append-run-log.mjs create mode 100755 scripts/ci-audit-gates.sh create mode 100755 scripts/ci-validate-gates.sh diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 178dd39..3813b8e 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -25,49 +25,7 @@ jobs: cache-dependency-path: tools/loop-audit/package-lock.json - name: Build, test & run loop-audit on the reference + starters - run: | - cd tools/loop-audit - npm ci - npm test - echo "=== Audit of repo root ===" - node dist/cli.js ../../ --json > /tmp/root-audit.json - echo "" - echo "=== Audit of starters (L1 gate) ===" - FAILED=0 - for s in ../../starters/*/; do - NAME=$(basename "$s") - node dist/cli.js "$s" --json > "/tmp/starter-${NAME}.json" - node -e " - const data = JSON.parse(require('fs').readFileSync('/tmp/starter-${NAME}.json', 'utf8')); - console.log('--- ${NAME}: score=' + data.score + ' level=' + data.level); - if (data.score < 38) { - console.error('Starter ${NAME} below L1 threshold (38): ' + data.score); - process.exit(1); - } - " || FAILED=1 - done - if [ "$FAILED" -ne 0 ]; then - echo "One or more starters failed L1 gate" - exit 1 - fi - cp /tmp/root-audit.json /tmp/audit.json - - - name: Report score and gate on critically low (reference is source, not a consumer project) - run: | - node -e ' - const fs = require("fs"); - try { - const data = JSON.parse(fs.readFileSync("/tmp/audit.json", "utf8")); - console.log("Reference score: " + data.score); - if (data.score < 58) { - console.error("Reference score below L2 threshold (58). Restore dogfood signals: STATE.md, skills/, AGENTS.md."); - process.exit(2); - } - } catch (e) { - console.error("Could not parse audit JSON:", e.message); - process.exit(1); - } - ' + run: bash scripts/ci-audit-gates.sh - name: Comment PR with loop readiness score if: github.event_name == 'pull_request' diff --git a/.github/workflows/daily-triage.yml b/.github/workflows/daily-triage.yml index f03a847..5cdc948 100644 --- a/.github/workflows/daily-triage.yml +++ b/.github/workflows/daily-triage.yml @@ -9,6 +9,7 @@ permissions: contents: write pull-requests: write issues: write + statuses: write jobs: triage: @@ -16,6 +17,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Record run start + id: timing + run: echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v6 with: node-version: '22' @@ -82,23 +87,70 @@ jobs: Run log: Updated by \`.github/workflows/daily-triage.yml\`. See \`LOOP.md\` for cadence and gates. EOF - - name: Open PR for STATE.md if changed + - name: Append loop-run-log.md + id: runlog + run: | + START="${{ steps.timing.outputs.started_at }}" + END=$(date -u +%Y-%m-%dT%H:%M:%SZ) + DURATION=$(node -e " + const s = new Date('${START}').getTime(); + const e = new Date('${END}').getTime(); + console.log(Math.max(1, Math.round((e - s) / 1000))); + ") + FAILING="${{ steps.workflows.outputs.failing }}" + SCORE="${{ steps.audit.outputs.score }}" + if [ "$FAILING" -gt 0 ]; then + OUTCOME="escalated" + else + OUTCOME="report-only" + fi + ENTRY=$(node -e " + console.log(JSON.stringify({ + run_id: '${END}', + pattern: 'daily-triage', + duration_s: Number('${DURATION}'), + items_found: Number('${FAILING}') + 1, + actions_taken: 1, + escalations: Number('${FAILING}'), + tokens_estimate: 52000, + readiness_score: Number('${SCORE}'), + outcome: '${OUTCOME}', + workflow_run: '${{ github.run_id }}' + })); + ") + node scripts/append-run-log.mjs "$ENTRY" + echo "outcome=${OUTCOME}" >> "$GITHUB_OUTPUT" + + - name: Run validate gates (for PR status) + id: validate_gates + run: bash scripts/ci-validate-gates.sh + + - name: Run audit gates (for PR status) + id: audit_gates + run: bash scripts/ci-audit-gates.sh + + - name: Open PR for STATE.md + loop-run-log if changed + id: pr env: GH_TOKEN: ${{ github.token }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if git diff --quiet STATE.md; then - echo "No STATE.md changes — skip PR" + if git diff --quiet STATE.md loop-run-log.md; then + echo "No STATE.md or loop-run-log.md changes — skip PR" + echo "opened=false" >> "$GITHUB_OUTPUT" exit 0 fi BRANCH="automated/daily-triage-$(date -u +%Y-%m-%d)" - git checkout -b "$BRANCH" - git add STATE.md - git commit -m "chore(loop): daily triage update STATE.md [automated]" + git fetch origin "$BRANCH" 2>/dev/null && git checkout "$BRANCH" || git checkout -b "$BRANCH" + git add STATE.md loop-run-log.md + git commit -m "chore(loop): daily triage update STATE.md + run log [automated]" git push -u origin "$BRANCH" + echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + echo "opened=true" >> "$GITHUB_OUTPUT" EXISTING=$(gh pr list --head "${{ github.repository_owner }}:${BRANCH}" --base main --state open --json number -q '.[0].number' 2>/dev/null || true) if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then @@ -112,12 +164,39 @@ jobs: --body "Automated daily triage update from \`daily-triage.yml\`. - Loop readiness: **${{ steps.audit.outputs.score }}** (${{ steps.audit.outputs.level }}) - - Merges automatically when \`validate\` and \`audit\` pass." + - Run log appended (\`${{ steps.runlog.outputs.outcome }}\`) + - \`validate\` and \`audit\` statuses posted inline (GITHUB_TOKEN does not trigger PR workflows)." PR_NUMBER=$(gh pr view "$BRANCH" --json number -q '.number') fi - + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" gh pr merge "$PR_NUMBER" --auto --squash --delete-branch + - name: Post required commit statuses for branch protection + if: steps.pr.outputs.opened == 'true' + uses: actions/github-script@v9 + with: + script: | + const sha = '${{ steps.pr.outputs.head_sha }}'; + if (!sha) { + core.setFailed('Missing head_sha for commit statuses'); + return; + } + const checks = [ + { context: 'validate', description: 'Pattern/registry gates (daily-triage inline)' }, + { context: 'audit', description: 'Loop readiness gates (daily-triage inline)' }, + ]; + for (const check of checks) { + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha, + state: 'success', + context: check.context, + description: check.description, + target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + }); + } + - name: Open weekly loop report issue (Mondays) if: github.event.schedule == '0 8 * * 1-5' && format('{0}', github.run_attempt) == '1' run: | @@ -135,7 +214,8 @@ jobs: --body "## Automated daily triage summary - Loop readiness: **${{ steps.audit.outputs.score }}** (${{ steps.audit.outputs.level }}) - - See updated \`STATE.md\` on main + - Latest run outcome: \`${{ steps.runlog.outputs.outcome }}\` (see \`loop-run-log.md\`) + - See updated \`STATE.md\` on main after PR merge - Review high-priority items and close or re-prioritize _Generated by daily-triage workflow. Human reviews and decides actions._" diff --git a/.github/workflows/validate-patterns.yml b/.github/workflows/validate-patterns.yml index 85b9a8d..ca0c68e 100644 --- a/.github/workflows/validate-patterns.yml +++ b/.github/workflows/validate-patterns.yml @@ -16,48 +16,5 @@ jobs: with: node-version: '22' - - name: Check registry covers all pattern files - run: | - set -e - echo "Patterns declared in registry:" - grep '^\s*-\s*id:' patterns/registry.yaml | sed 's/.*id: //' | sort > /tmp/registry.txt - echo "Pattern markdown files:" - ls patterns/*.md | xargs -n1 basename | sed 's/.md$//' | grep -v README | sort > /tmp/files.txt - echo "=== Registry ==="; cat /tmp/registry.txt - echo "=== Files ==="; cat /tmp/files.txt - comm -23 /tmp/files.txt /tmp/registry.txt | grep . && (echo "ERROR: pattern md file(s) missing from registry.yaml"; exit 1) || echo "All pattern files registered ✓" - comm -23 /tmp/registry.txt /tmp/files.txt | grep . && (echo "ERROR: registry entry without matching .md"; exit 1) || echo "No orphan registry entries ✓" - - - name: Verify CONTRIBUTING + template compliance (basic) - run: | - set -e - echo "Checking that key sections exist in patterns (lightweight)" - for f in patterns/*.md; do - if [[ "$f" == *"README.md" ]]; then continue; fi - grep -q "^## Scheduling" "$f" || { echo "Missing Scheduling in $f"; exit 1; } - grep -q "^## Required Skills" "$f" || { echo "Missing Required Skills in $f"; exit 1; } - grep -Eq "maker.*checker|verifier|Maker / Checker|Verification Strategy|reviewer sub-agent" "$f" || { echo "Missing verifier strategy (maker/checker) mention in $f"; exit 1; } - done - echo "Basic pattern structure checks passed ✓" - - - name: Ensure templates exist - run: | - test -f templates/pattern-template.md || (echo "Missing pattern-template.md"; exit 1) - test -f templates/STATE.md.template || (echo "Missing STATE template"; exit 1) - test -f templates/loop-run-log.md.template || (echo "Missing loop-run-log template"; exit 1) - test -f templates/loop-budget.md.template || (echo "Missing loop-budget template"; exit 1) - echo "Templates present ✓" - - - name: Validate registry.yaml schema and starter paths - run: | - npm install --no-save yaml@2 ajv@8 - node scripts/validate-registry.mjs - - - name: Verify loop-init pattern sync - run: node scripts/check-loop-init-sync.mjs - - - name: Smoke test loop-init package layout - run: | - cd tools/loop-init - npm ci - npm test \ No newline at end of file + - name: Run validate gates + run: bash scripts/ci-validate-gates.sh \ No newline at end of file diff --git a/LOOP.md b/LOOP.md index b34af43..7d3efba 100644 --- a/LOOP.md +++ b/LOOP.md @@ -54,7 +54,7 @@ See [docs/multi-loop.md](docs/multi-loop.md). Priority: CI Sweeper → PR Babysi ## Budget & Observability - Token caps: `loop-budget.md` -- Run history: `loop-run-log.md` +- Run history: `loop-run-log.md` (appended each weekday run by `daily-triage.yml`) - Estimate: `npx @cobusgreyling/loop-cost --pattern daily-triage` - Kill switch: `loop-pause-all` label or flag in `STATE.md` diff --git a/scripts/append-run-log.mjs b/scripts/append-run-log.mjs new file mode 100644 index 0000000..0e1fdbf --- /dev/null +++ b/scripts/append-run-log.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Append one JSON run entry to loop-run-log.md and prune entries older than 30 days. + * Usage: node scripts/append-run-log.mjs '' [path-to-log] + */ +import { readFile, writeFile } from 'node:fs/promises'; + +const MARKER = ''; +const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +const entryJson = process.argv[2]; +const logPath = process.argv[3] || 'loop-run-log.md'; + +if (!entryJson) { + console.error('Usage: node scripts/append-run-log.mjs \'\' [loop-run-log.md]'); + process.exit(1); +} + +const entry = JSON.parse(entryJson); +const content = await readFile(logPath, 'utf8'); +const markerAt = content.indexOf(MARKER); +if (markerAt === -1) { + console.error(`Marker not found in ${logPath}`); + process.exit(1); +} + +const before = content.slice(0, markerAt + MARKER.length); +const after = content.slice(markerAt + MARKER.length); +const now = Date.now(); + +const kept = []; +for (const line of after.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) continue; + try { + const obj = JSON.parse(trimmed); + const t = new Date(obj.run_id).getTime(); + if (!Number.isNaN(t) && now - t <= MAX_AGE_MS) { + kept.push(trimmed); + } + } catch { + // skip malformed lines + } +} + +kept.push(JSON.stringify(entry)); +await writeFile(logPath, `${before}\n\n${kept.join('\n')}\n`); +console.log(`Appended run ${entry.run_id} (${kept.length} entries within 30d window)`); \ No newline at end of file diff --git a/scripts/ci-audit-gates.sh b/scripts/ci-audit-gates.sh new file mode 100755 index 0000000..79e8c94 --- /dev/null +++ b/scripts/ci-audit-gates.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Loop readiness audit gates — shared by audit.yml and daily-triage.yml +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT/tools/loop-audit" +npm ci +npm test +echo "=== Audit of repo root ===" +node dist/cli.js "$REPO_ROOT" --json > /tmp/root-audit.json +echo "" +echo "=== Audit of starters (L1 gate) ===" +FAILED=0 +for s in "$REPO_ROOT"/starters/*/; do + NAME=$(basename "$s") + node dist/cli.js "$s" --json > "/tmp/starter-${NAME}.json" + node -e " + const data = JSON.parse(require('fs').readFileSync('/tmp/starter-${NAME}.json', 'utf8')); + console.log('--- ${NAME}: score=' + data.score + ' level=' + data.level); + if (data.score < 38) { + console.error('Starter ${NAME} below L1 threshold (38): ' + data.score); + process.exit(1); + } + " || FAILED=1 +done +if [ "$FAILED" -ne 0 ]; then + echo "One or more starters failed L1 gate" + exit 1 +fi +cp /tmp/root-audit.json /tmp/audit.json + +node -e ' + const fs = require("fs"); + const data = JSON.parse(fs.readFileSync("/tmp/audit.json", "utf8")); + console.log("Reference score: " + data.score); + if (data.score < 58) { + console.error("Reference score below L2 threshold (58). Restore dogfood signals: STATE.md, skills/, AGENTS.md."); + process.exit(2); + } +' +echo "audit gates passed ✓" \ No newline at end of file diff --git a/scripts/ci-validate-gates.sh b/scripts/ci-validate-gates.sh new file mode 100755 index 0000000..1550c0d --- /dev/null +++ b/scripts/ci-validate-gates.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Pattern/registry validation gates — shared by validate-patterns.yml and daily-triage.yml +set -euo pipefail + +echo "Patterns declared in registry:" +grep '^\s*-\s*id:' patterns/registry.yaml | sed 's/.*id: //' | sort > /tmp/registry.txt +echo "Pattern markdown files:" +ls patterns/*.md | xargs -n1 basename | sed 's/.md$//' | grep -v README | sort > /tmp/files.txt +echo "=== Registry ==="; cat /tmp/registry.txt +echo "=== Files ==="; cat /tmp/files.txt +comm -23 /tmp/files.txt /tmp/registry.txt | grep . && (echo "ERROR: pattern md file(s) missing from registry.yaml"; exit 1) || echo "All pattern files registered ✓" +comm -23 /tmp/registry.txt /tmp/files.txt | grep . && (echo "ERROR: registry entry without matching .md"; exit 1) || echo "No orphan registry entries ✓" + +echo "Checking that key sections exist in patterns (lightweight)" +for f in patterns/*.md; do + if [[ "$f" == *"README.md" ]]; then continue; fi + grep -q "^## Scheduling" "$f" || { echo "Missing Scheduling in $f"; exit 1; } + grep -q "^## Required Skills" "$f" || { echo "Missing Required Skills in $f"; exit 1; } + grep -Eq "maker.*checker|verifier|Maker / Checker|Verification Strategy|reviewer sub-agent" "$f" || { echo "Missing verifier strategy (maker/checker) mention in $f"; exit 1; } +done +echo "Basic pattern structure checks passed ✓" + +test -f templates/pattern-template.md || (echo "Missing pattern-template.md"; exit 1) +test -f templates/STATE.md.template || (echo "Missing STATE template"; exit 1) +test -f templates/loop-run-log.md.template || (echo "Missing loop-run-log template"; exit 1) +test -f templates/loop-budget.md.template || (echo "Missing loop-budget template"; exit 1) +echo "Templates present ✓" + +npm install --no-save yaml@2 ajv@8 +node scripts/validate-registry.mjs +node scripts/check-loop-init-sync.mjs + +cd tools/loop-init +npm ci +npm test +echo "validate gates passed ✓" \ No newline at end of file