Merge pull request #261 from yuens1002/contrib/yuens1002/per-request-mcp #36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: OB1 PR Gate | ||
| # This workflow used to live at `.github/workflows/ob1-gate.yml`. | ||
| # It was renamed to force GitHub Actions to register a fresh workflow after | ||
| # the old workflow id began creating runs with zero jobs. | ||
| # | ||
| # Branch protection recommendation: | ||
| # After this action is working, enable branch protection on main: | ||
| # - Require the "OB1 PR Gate" workflow to pass its checks | ||
| # - Require at least 1 approving review from a maintainer | ||
| # This means: automated agent passes → human admin approves → merge allowed | ||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize, reopened] | ||
| branches: [main] | ||
| permissions: | ||
| contents: read | ||
| jobs: | ||
| review: | ||
| name: OB1 Review | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout PR head safely | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| ref: refs/pull/${{ github.event.pull_request.number }}/head | ||
| fetch-depth: 0 | ||
| - name: Fetch base branch | ||
| run: git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1 | ||
| - name: Install metadata schema validator | ||
| run: python3 -m pip install check-jsonschema | ||
| - name: Get changed files | ||
| id: changed | ||
| run: | | ||
| # Get files changed in this PR | ||
| changed=$(git diff --name-only origin/main...HEAD 2>/dev/null || git diff --name-only HEAD~1 HEAD) | ||
| echo "files<<EOF" >> $GITHUB_OUTPUT | ||
| echo "$changed" >> $GITHUB_OUTPUT | ||
| echo "EOF" >> $GITHUB_OUTPUT | ||
| # Identify contribution folders (e.g., recipes/email-import/) | ||
| # Exclude _template folders and top-level files | ||
| contrib_dirs=$(echo "$changed" | grep -E '^(recipes|schemas|dashboards|integrations|skills|primitives|extensions)/' | grep -v '/_template/' | cut -d'/' -f1,2 | sort -u || true) | ||
| echo "contrib_dirs<<EOF" >> $GITHUB_OUTPUT | ||
| echo "$contrib_dirs" >> $GITHUB_OUTPUT | ||
| echo "EOF" >> $GITHUB_OUTPUT | ||
| - name: Run review checks | ||
| id: review | ||
| run: | | ||
| CHANGED_FILES="${{ steps.changed.outputs.files }}" | ||
| CONTRIB_DIRS="${{ steps.changed.outputs.contrib_dirs }}" | ||
| PR_TITLE="${{ github.event.pull_request.title }}" | ||
| pass_count=0 | ||
| fail_count=0 | ||
| results="" | ||
| secret_blocked=false | ||
| # Helper functions | ||
| pass_check() { | ||
| results="${results}✅ **$1** — $2\n" | ||
| pass_count=$((pass_count + 1)) | ||
| } | ||
| fail_check() { | ||
| results="${results}❌ **$1** — $2\n" | ||
| fail_count=$((fail_count + 1)) | ||
| } | ||
| # Skip contribution checks for docs/governance PRs. | ||
| # Detected by PR title prefix OR by no contribution dirs being touched. | ||
| is_docs_pr=false | ||
| if echo "$PR_TITLE" | grep -qiE '^\[docs\]'; then | ||
| is_docs_pr=true | ||
| elif [ -z "$CONTRIB_DIRS" ]; then | ||
| is_docs_pr=true | ||
| fi | ||
| if [ "$is_docs_pr" = true ]; then | ||
| results="This PR modifies docs or repo governance files. Contribution checks skipped.\n\n✅ No issues found." | ||
| echo "comment<<EOF" >> $GITHUB_OUTPUT | ||
| echo -e "## OB1 PR Gate\n\n${results}" >> $GITHUB_OUTPUT | ||
| echo "EOF" >> $GITHUB_OUTPUT | ||
| echo "failed=false" >> $GITHUB_OUTPUT | ||
| exit 0 | ||
| fi | ||
| # ─── Rule 1: Folder structure ─── | ||
| rule1_pass=true | ||
| bad_files="" | ||
| allowed_pattern='^(recipes|schemas|dashboards|integrations|skills|primitives|extensions|docs|resources|\.github|\.gitkeep|\.gitignore|\.markdownlint)' | ||
| while IFS= read -r f; do | ||
| [ -z "$f" ] && continue | ||
| if ! echo "$f" | grep -qE "$allowed_pattern"; then | ||
| bad_files="${bad_files} - \`$f\`\n" | ||
| rule1_pass=false | ||
| fi | ||
| done <<< "$CHANGED_FILES" | ||
| if [ "$rule1_pass" = true ]; then | ||
| pass_check "Folder structure" "All files are in allowed directories" | ||
| else | ||
| fail_check "Folder structure" "Files outside allowed directories:\n${bad_files}" | ||
| fi | ||
| # ─── Rule 2: Required files ─── | ||
| rule2_pass=true | ||
| rule2_detail="" | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| if [ ! -f "$dir/README.md" ]; then | ||
| rule2_detail="${rule2_detail} - \`$dir/README.md\` missing\n" | ||
| rule2_pass=false | ||
| fi | ||
| if [ ! -f "$dir/metadata.json" ]; then | ||
| rule2_detail="${rule2_detail} - \`$dir/metadata.json\` missing\n" | ||
| rule2_pass=false | ||
| fi | ||
| done <<< "$CONTRIB_DIRS" | ||
| if [ "$rule2_pass" = true ]; then | ||
| pass_check "Required files" "README.md and metadata.json found in all contribution folders" | ||
| else | ||
| fail_check "Required files" "Missing required files:\n${rule2_detail}" | ||
| fi | ||
| # ─── Rule 3: Metadata valid ─── | ||
| rule3_pass=true | ||
| rule3_detail="" | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| [ ! -f "$dir/metadata.json" ] && continue | ||
| # Check valid JSON | ||
| if ! jq empty "$dir/metadata.json" 2>/dev/null; then | ||
| rule3_detail="${rule3_detail} - \`$dir/metadata.json\` is not valid JSON\n" | ||
| rule3_pass=false | ||
| continue | ||
| fi | ||
| if ! schema_output=$(check-jsonschema --schemafile .github/metadata.schema.json "$dir/metadata.json" 2>&1); then | ||
| indented_output=$(printf '%s\n' "$schema_output" | sed 's/^/ /') | ||
| rule3_detail="${rule3_detail} - \`$dir/metadata.json\` failed schema validation\n${indented_output}\n" | ||
| rule3_pass=false | ||
| fi | ||
| done <<< "$CONTRIB_DIRS" | ||
| if [ "$rule3_pass" = true ]; then | ||
| pass_check "Metadata valid" "All metadata.json files passed JSON Schema validation" | ||
| else | ||
| fail_check "Metadata valid" "Metadata issues:\n${rule3_detail}" | ||
| fi | ||
| # ─── Rule 4: No credentials ─── | ||
| rule4_pass=true | ||
| rule4_detail="" | ||
| # Scan all changed text files for credential patterns. | ||
| while IFS= read -r f; do | ||
| [ -z "$f" ] && continue | ||
| [ ! -f "$f" ] && continue | ||
| # Skip binary files to avoid noisy false positives. | ||
| if ! grep -Iq . "$f"; then | ||
| continue | ||
| fi | ||
| matches=$(grep -nE '(sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|SUPABASE_SERVICE_ROLE_KEY\s*=\s*"?ey)' "$f" 2>/dev/null || true) | ||
| if [ -n "$matches" ]; then | ||
| rule4_detail="${rule4_detail} - \`$f\`: potential credential found\n" | ||
| rule4_pass=false | ||
| secret_blocked=true | ||
| fi | ||
| # Check for .env files with actual values | ||
| if echo "$f" | grep -qE '\.env$'; then | ||
| has_values=$(grep -E '^[A-Z_]+=.+' "$f" 2>/dev/null | grep -vE '=(your-|<|example|placeholder|TODO)' || true) | ||
| if [ -n "$has_values" ]; then | ||
| rule4_detail="${rule4_detail} - \`$f\`: .env file appears to contain real values\n" | ||
| rule4_pass=false | ||
| secret_blocked=true | ||
| fi | ||
| fi | ||
| done <<< "$CHANGED_FILES" | ||
| if [ "$rule4_pass" = true ]; then | ||
| pass_check "No credentials" "No API keys, tokens, or secrets detected" | ||
| else | ||
| fail_check "No credentials" "Potential credentials found:\n${rule4_detail}" | ||
| fi | ||
| # ─── Rule 5: SQL safety ─── | ||
| rule5_pass=true | ||
| rule5_detail="" | ||
| while IFS= read -r f; do | ||
| [ -z "$f" ] && continue | ||
| [ ! -f "$f" ] && continue | ||
| case "$f" in *.sql) ;; *) continue ;; esac | ||
| # Check for destructive operations | ||
| dangerous=$(grep -niE '(DROP\s+TABLE|DROP\s+DATABASE|TRUNCATE)' "$f" 2>/dev/null || true) | ||
| if [ -n "$dangerous" ]; then | ||
| rule5_detail="${rule5_detail} - \`$f\`: contains DROP/TRUNCATE statement\n" | ||
| rule5_pass=false | ||
| fi | ||
| # Check for DELETE without WHERE | ||
| deletes=$(grep -niE 'DELETE\s+FROM' "$f" 2>/dev/null || true) | ||
| if [ -n "$deletes" ]; then | ||
| while IFS= read -r line; do | ||
| if ! echo "$line" | grep -qiE 'WHERE'; then | ||
| rule5_detail="${rule5_detail} - \`$f\` line $(echo "$line" | cut -d: -f1): DELETE FROM without WHERE clause\n" | ||
| rule5_pass=false | ||
| fi | ||
| done <<< "$deletes" | ||
| fi | ||
| # Check for altering/dropping core thoughts table columns | ||
| alter_thoughts=$(grep -niE 'ALTER\s+TABLE\s+thoughts\s+(DROP|ALTER)\s+COLUMN' "$f" 2>/dev/null || true) | ||
| if [ -n "$alter_thoughts" ]; then | ||
| rule5_detail="${rule5_detail} - \`$f\`: modifies core thoughts table columns (only ADD COLUMN is allowed)\n" | ||
| rule5_pass=false | ||
| fi | ||
| done <<< "$CHANGED_FILES" | ||
| if [ "$rule5_pass" = true ]; then | ||
| pass_check "SQL safety" "No destructive SQL or core table modifications" | ||
| else | ||
| fail_check "SQL safety" "SQL safety issues:\n${rule5_detail}" | ||
| fi | ||
| # ─── Rule 6: Category-specific artifacts ─── | ||
| rule6_pass=true | ||
| rule6_detail="" | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| category=$(echo "$dir" | cut -d'/' -f1) | ||
| dir_files=$(find "$dir" -type f 2>/dev/null | grep -v README.md | grep -v metadata.json || true) | ||
| case "$category" in | ||
| recipes) | ||
| has_artifact=$(echo "$dir_files" | grep -E '\.(sql|ts|js|py)$' || true) | ||
| readme_has_steps=$(grep -c -iE '^\s*[0-9]+\.' "$dir/README.md" 2>/dev/null || echo "0") | ||
| if [ -z "$has_artifact" ] && [ "$readme_has_steps" -lt 3 ]; then | ||
| rule6_detail="${rule6_detail} - \`$dir\`: recipes need code files (.sql/.ts/.js/.py) or detailed step-by-step instructions in README\n" | ||
| rule6_pass=false | ||
| fi | ||
| ;; | ||
| schemas) | ||
| has_sql=$(echo "$dir_files" | grep -E '\.sql$' || true) | ||
| if [ -z "$has_sql" ]; then | ||
| rule6_detail="${rule6_detail} - \`$dir\`: schemas must contain at least one .sql file\n" | ||
| rule6_pass=false | ||
| fi | ||
| ;; | ||
| dashboards) | ||
| has_frontend=$(echo "$dir_files" | grep -E '\.(html|jsx|tsx|vue|svelte)$' || true) | ||
| has_pkg=$(find "$dir" -name "package.json" 2>/dev/null || true) | ||
| if [ -z "$has_frontend" ] && [ -z "$has_pkg" ]; then | ||
| rule6_detail="${rule6_detail} - \`$dir\`: dashboards must contain frontend code (.html/.jsx/.tsx/.vue/.svelte) or package.json\n" | ||
| rule6_pass=false | ||
| fi | ||
| ;; | ||
| integrations) | ||
| has_code=$(echo "$dir_files" | grep -E '\.(ts|js|py)$' || true) | ||
| if [ -z "$has_code" ]; then | ||
| rule6_detail="${rule6_detail} - \`$dir\`: integrations must contain code files (.ts/.js/.py)\n" | ||
| rule6_pass=false | ||
| fi | ||
| ;; | ||
| skills) | ||
| has_skill=$(echo "$dir_files" | grep -iE '(^|/)(SKILL\.md|[^/]+[.-]skill\.md)$' || true) | ||
| if [ -z "$has_skill" ]; then | ||
| rule6_detail="${rule6_detail} - \`$dir\`: skills must contain a plain-text skill file (\`SKILL.md\`, \`*.skill.md\`, or \`*-skill.md\`)\n" | ||
| rule6_pass=false | ||
| fi | ||
| ;; | ||
| primitives) | ||
| # Primitives must have substantial README (200+ words) | ||
| if [ -f "$dir/README.md" ]; then | ||
| word_count=$(wc -w < "$dir/README.md" 2>/dev/null || echo "0") | ||
| if [ "$word_count" -lt 200 ]; then | ||
| rule6_detail="${rule6_detail} - \`$dir\`: primitive README must be substantial (200+ words, found ${word_count})\n" | ||
| rule6_pass=false | ||
| fi | ||
| fi | ||
| ;; | ||
| extensions) | ||
| # Extensions must have both SQL and code files | ||
| has_sql=$(echo "$dir_files" | grep -E '\.sql$' || true) | ||
| has_code=$(echo "$dir_files" | grep -E '\.(ts|js|py)$' || true) | ||
| if [ -z "$has_sql" ]; then | ||
| rule6_detail="${rule6_detail} - \`$dir\`: extensions must contain at least one .sql file\n" | ||
| rule6_pass=false | ||
| fi | ||
| if [ -z "$has_code" ]; then | ||
| rule6_detail="${rule6_detail} - \`$dir\`: extensions must contain code files (.ts/.js/.py)\n" | ||
| rule6_pass=false | ||
| fi | ||
| ;; | ||
| esac | ||
| done <<< "$CONTRIB_DIRS" | ||
| if [ "$rule6_pass" = true ]; then | ||
| pass_check "Category artifacts" "Required file types present for each category" | ||
| else | ||
| fail_check "Category artifacts" "Missing category-specific files:\n${rule6_detail}" | ||
| fi | ||
| # ─── Rule 7: PR format ─── | ||
| if echo "$PR_TITLE" | grep -qE '^\[(recipes|schemas|dashboards|integrations|skills|primitives|extensions|docs)\] '; then | ||
| pass_check "PR format" "Title follows \`[category] Description\` format" | ||
| else | ||
| fail_check "PR format" "PR title must start with \`[recipes]\`, \`[schemas]\`, \`[dashboards]\`, \`[integrations]\`, \`[skills]\`, \`[primitives]\`, \`[extensions]\`, or \`[docs]\` followed by a space and description" | ||
| fi | ||
| # ─── Rule 8: No binary blobs ─── | ||
| rule8_pass=true | ||
| rule8_detail="" | ||
| while IFS= read -r f; do | ||
| [ -z "$f" ] && continue | ||
| [ ! -f "$f" ] && continue | ||
| # Check file size (1MB = 1048576 bytes) | ||
| size=$(wc -c < "$f" 2>/dev/null || echo "0") | ||
| if [ "$size" -gt 1048576 ]; then | ||
| rule8_detail="${rule8_detail} - \`$f\`: file is $(( size / 1024 ))KB (max 1MB)\n" | ||
| rule8_pass=false | ||
| fi | ||
| # Check banned extensions | ||
| if echo "$f" | grep -qE '\.(exe|dmg|zip|tar\.gz|tar\.bz2|rar|7z|msi|pkg|deb|rpm)$'; then | ||
| rule8_detail="${rule8_detail} - \`$f\`: binary/archive files not allowed\n" | ||
| rule8_pass=false | ||
| fi | ||
| done <<< "$CHANGED_FILES" | ||
| if [ "$rule8_pass" = true ]; then | ||
| pass_check "No binary blobs" "No oversized or binary files" | ||
| else | ||
| fail_check "No binary blobs" "Binary/large file issues:\n${rule8_detail}" | ||
| fi | ||
| # ─── Rule 9: README completeness ─── | ||
| rule9_pass=true | ||
| rule9_detail="" | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| [ ! -f "$dir/README.md" ] && continue | ||
| readme="$dir/README.md" | ||
| missing_sections="" | ||
| if ! grep -qi 'prerequisite' "$readme"; then | ||
| missing_sections="${missing_sections}Prerequisites, " | ||
| fi | ||
| if ! grep -qiE '^\s*[0-9]+\.' "$readme"; then | ||
| missing_sections="${missing_sections}Step-by-step instructions, " | ||
| fi | ||
| if ! grep -qiE '(expected|outcome|result)' "$readme"; then | ||
| missing_sections="${missing_sections}Expected outcome, " | ||
| fi | ||
| if [ -n "$missing_sections" ]; then | ||
| rule9_detail="${rule9_detail} - \`$dir/README.md\`: missing sections: ${missing_sections%%, }\n" | ||
| rule9_pass=false | ||
| fi | ||
| done <<< "$CONTRIB_DIRS" | ||
| if [ "$rule9_pass" = true ]; then | ||
| pass_check "README completeness" "All READMEs include Prerequisites, Steps, and Expected Outcome" | ||
| else | ||
| fail_check "README completeness" "Incomplete READMEs:\n${rule9_detail}" | ||
| fi | ||
| # ─── Rule 10: Contribution dependency validation ─── | ||
| rule10_pass=true | ||
| rule10_detail="" | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| [ ! -f "$dir/metadata.json" ] && continue | ||
| # Check if this contribution declares requires_primitives | ||
| primitives=$(jq -r '.requires_primitives // [] | .[]' "$dir/metadata.json" 2>/dev/null || true) | ||
| readme="$dir/README.md" | ||
| while IFS= read -r prim; do | ||
| [ -z "$prim" ] && continue | ||
| # (a) Check that the primitive directory exists in the repo | ||
| if [ ! -d "primitives/$prim" ]; then | ||
| rule10_detail="${rule10_detail} - \`$dir\`: requires primitive \`$prim\` but \`primitives/$prim/\` does not exist\n" | ||
| rule10_pass=false | ||
| fi | ||
| # (b) Check that the README links to the primitive | ||
| if [ -f "$readme" ]; then | ||
| if ! grep -q "primitives/$prim" "$readme"; then | ||
| rule10_detail="${rule10_detail} - \`$dir/README.md\`: declares dependency on primitive \`$prim\` but does not link to it\n" | ||
| rule10_pass=false | ||
| fi | ||
| fi | ||
| done <<< "$primitives" | ||
| # Check if this contribution declares requires_skills | ||
| skills=$(jq -r '.requires_skills // [] | .[]' "$dir/metadata.json" 2>/dev/null || true) | ||
| while IFS= read -r skill; do | ||
| [ -z "$skill" ] && continue | ||
| # (a) Check that the skill directory exists in the repo | ||
| if [ ! -d "skills/$skill" ]; then | ||
| rule10_detail="${rule10_detail} - \`$dir\`: requires skill \`$skill\` but \`skills/$skill/\` does not exist\n" | ||
| rule10_pass=false | ||
| fi | ||
| # (b) Check that the README links to the skill | ||
| if [ -f "$readme" ]; then | ||
| if ! grep -q "skills/$skill" "$readme"; then | ||
| rule10_detail="${rule10_detail} - \`$dir/README.md\`: declares dependency on skill \`$skill\` but does not link to it\n" | ||
| rule10_pass=false | ||
| fi | ||
| fi | ||
| done <<< "$skills" | ||
| done <<< "$CONTRIB_DIRS" | ||
| if [ "$rule10_pass" = true ]; then | ||
| pass_check "Contribution dependencies" "All declared skill and primitive dependencies exist and are linked in README" | ||
| else | ||
| fail_check "Contribution dependencies" "Dependency issues:\n${rule10_detail}" | ||
| fi | ||
| # ─── Rule 11: LLM clarity review ─── | ||
| # Clarity, alignment, and design pattern reviews are handled by the | ||
| # Claude PR Review workflow (claude-review.yml) using claude-code-action. | ||
| # This keeps the PR gate fast and deterministic. | ||
| pass_check "LLM clarity review" "Covered by Claude PR Review workflow" | ||
| # ─── Rule 14: No local MCP pattern ─── | ||
| # Extensions and integrations must use remote MCP (Edge Functions + custom connectors). | ||
| # The old pattern of editing claude_desktop_config.json with local Node.js servers | ||
| # is not allowed — it's inconsistent with the core Open Brain setup. | ||
| rule14_pass=true | ||
| rule14_detail="" | ||
| while IFS= read -r f; do | ||
| [ -z "$f" ] && continue | ||
| [ ! -f "$f" ] && continue | ||
| case "$f" in *.md|*.ts|*.js) ;; *) continue ;; esac | ||
| # Check for old local MCP config pattern | ||
| local_mcp=$(grep -niE '(claude_desktop_config|"command":\s*"node"|StdioServerTransport|mcpServers.*command)' "$f" 2>/dev/null || true) | ||
| if [ -n "$local_mcp" ]; then | ||
| # Skip _template files (they might reference the pattern to warn against it) | ||
| if echo "$f" | grep -q '_template'; then | ||
| continue | ||
| fi | ||
| rule14_detail="${rule14_detail} - \`$f\`: references local MCP pattern (claude_desktop_config.json or stdio transport). Extensions must use remote MCP via Supabase Edge Functions.\n" | ||
| rule14_pass=false | ||
| fi | ||
| done <<< "$CHANGED_FILES" | ||
| if [ "$rule14_pass" = true ]; then | ||
| pass_check "Remote MCP pattern" "No local MCP server patterns detected — uses remote MCP correctly" | ||
| else | ||
| fail_check "Remote MCP pattern" "Local MCP patterns found (must use remote MCP via Edge Functions):\n${rule14_detail}See the [Getting Started guide](docs/01-getting-started.md) for the correct remote MCP pattern." | ||
| fi | ||
| # ─── Rule 15: Tool audit link ─── | ||
| # Extensions and integrations that expose MCP tools must link to the | ||
| # tool audit guide so users know how to manage their tool surface area. | ||
| rule15_pass=true | ||
| rule15_detail="" | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| category=$(echo "$dir" | cut -d'/' -f1) | ||
| case "$category" in | ||
| extensions|integrations) ;; | ||
| *) continue ;; | ||
| esac | ||
| [ ! -f "$dir/README.md" ] && continue | ||
| # Check for any link to the tool audit guide | ||
| if ! grep -q '05-tool-audit' "$dir/README.md"; then | ||
| rule15_detail="${rule15_detail} - \`$dir/README.md\`: missing link to [MCP Tool Audit & Optimization Guide](docs/05-tool-audit.md). Required for extensions and integrations that expose MCP tools.\n" | ||
| rule15_pass=false | ||
| fi | ||
| done <<< "$CONTRIB_DIRS" | ||
| if [ "$rule15_pass" = true ]; then | ||
| pass_check "Tool audit link" "Extensions/integrations link to the MCP Tool Audit guide" | ||
| else | ||
| fail_check "Tool audit link" "Missing tool audit guide link:\n${rule15_detail}See the extension template for the recommended format." | ||
| fi | ||
| # ─── Additional automated checks ──────────────────────────────── | ||
| # ─── Scope check ─── | ||
| # Contribution PRs should only modify files in their own folder | ||
| rule12_pass=true | ||
| rule12_detail="" | ||
| out_of_scope="" | ||
| while IFS= read -r f; do | ||
| [ -z "$f" ] && continue | ||
| in_scope=false | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| if echo "$f" | grep -qE "^${dir}/"; then | ||
| in_scope=true | ||
| break | ||
| fi | ||
| done <<< "$CONTRIB_DIRS" | ||
| if [ "$in_scope" = false ]; then | ||
| out_of_scope="${out_of_scope} - \`$f\`\n" | ||
| rule12_pass=false | ||
| fi | ||
| done <<< "$CHANGED_FILES" | ||
| if [ "$rule12_pass" = true ]; then | ||
| pass_check "Scope check" "All changes are within the contribution folder(s)" | ||
| else | ||
| fail_check "Scope check" "Files changed outside contribution folder(s):\n${out_of_scope}Contribution PRs should only modify files in their own folder." | ||
| fi | ||
| # ─── Internal link validation ─── | ||
| # Check that relative links in READMEs point to files that exist | ||
| rule13_pass=true | ||
| rule13_detail="" | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| [ ! -f "$dir/README.md" ] && continue | ||
| # Extract relative markdown links (not http, not anchors) | ||
| links=$(grep -oE '\]\([^)]+\)' "$dir/README.md" | sed 's/^\](//' | sed 's/)$//' | grep -v '^http' | grep -v '^#' || true) | ||
| while IFS= read -r link; do | ||
| [ -z "$link" ] && continue | ||
| # Strip anchor fragment | ||
| filepath=$(echo "$link" | sed 's/#.*//') | ||
| [ -z "$filepath" ] && continue | ||
| # Resolve relative to the contribution directory | ||
| resolved="$dir/$filepath" | ||
| if [ ! -e "$resolved" ]; then | ||
| rule13_detail="${rule13_detail} - \`$dir/README.md\`: broken link \`$link\` → target does not exist\n" | ||
| rule13_pass=false | ||
| fi | ||
| done <<< "$links" | ||
| done <<< "$CONTRIB_DIRS" | ||
| if [ "$rule13_pass" = true ]; then | ||
| pass_check "Internal links" "All relative links in READMEs resolve to existing files" | ||
| else | ||
| fail_check "Internal links" "Broken links found:\n${rule13_detail}" | ||
| fi | ||
| # ─── Post-merge reminders (non-blocking) ─────────────────────── | ||
| # These don't block merge — they flag tasks for admins after merge | ||
| reminders="" | ||
| while IFS= read -r dir; do | ||
| [ -z "$dir" ] && continue | ||
| category=$(echo "$dir" | cut -d'/' -f1) | ||
| folder_name=$(echo "$dir" | cut -d'/' -f2) | ||
| contrib_name=$(jq -r '.name // empty' "$dir/metadata.json" 2>/dev/null || echo "$folder_name") | ||
| author_name=$(jq -r '.author.name // empty' "$dir/metadata.json" 2>/dev/null || echo "unknown") | ||
| author_github=$(jq -r '.author.github // empty' "$dir/metadata.json" 2>/dev/null || echo "") | ||
| # Check category index file | ||
| index_file="$category/README.md" | ||
| if [ -f "$index_file" ]; then | ||
| if ! grep -q "$folder_name" "$index_file"; then | ||
| reminders="${reminders}- [ ] Add **${contrib_name}** to [\`${index_file}\`](${index_file})\n" | ||
| fi | ||
| fi | ||
| # Check root README community section | ||
| if [ -f "README.md" ]; then | ||
| if ! grep -qi "$folder_name" "README.md"; then | ||
| reminders="${reminders}- [ ] Add **${contrib_name}** to root README.md community contributions section\n" | ||
| fi | ||
| fi | ||
| # Check CONTRIBUTORS.md | ||
| if [ -f "CONTRIBUTORS.md" ]; then | ||
| found_contributor=false | ||
| if [ -n "$author_name" ] && grep -qi "$author_name" "CONTRIBUTORS.md"; then | ||
| found_contributor=true | ||
| fi | ||
| if [ -n "$author_github" ] && grep -qi "$author_github" "CONTRIBUTORS.md"; then | ||
| found_contributor=true | ||
| fi | ||
| if [ "$found_contributor" = false ]; then | ||
| reminders="${reminders}- [ ] Add **${author_name}**$([ -n "$author_github" ] && echo " (@${author_github})") to [CONTRIBUTORS.md](CONTRIBUTORS.md)\n" | ||
| fi | ||
| fi | ||
| done <<< "$CONTRIB_DIRS" | ||
| # Always remind about Discord | ||
| reminders="${reminders}- [ ] Post in OB1 Discord [#show-and-tell](https://discord.gg/Cgh9WJEkeG)\n" | ||
| # ─── Build summary ─── | ||
| total=$((pass_count + fail_count)) | ||
| if [ "$fail_count" -gt 0 ]; then | ||
| summary="**Result: ${pass_count}/${total} checks passed. Please fix the issues above and push again.**" | ||
| echo "failed=true" >> $GITHUB_OUTPUT | ||
| else | ||
| summary="**Result: All ${total} checks passed! Ready for human review.**" | ||
| echo "failed=false" >> $GITHUB_OUTPUT | ||
| fi | ||
| echo "secret_blocked=$secret_blocked" >> $GITHUB_OUTPUT | ||
| comment="## OB1 PR Gate\n\n${results}\n${summary}" | ||
| # Append post-merge reminders (non-blocking, for admins) | ||
| if [ -n "$reminders" ]; then | ||
| comment="${comment}\n\n---\n\n### Post-Merge Tasks\n\n*These don't block merge — they're reminders for admins after this PR lands.*\n\n${reminders}" | ||
| fi | ||
| echo "comment<<EOF" >> $GITHUB_OUTPUT | ||
| echo -e "$comment" >> $GITHUB_OUTPUT | ||
| echo "EOF" >> $GITHUB_OUTPUT | ||
| - name: Write gate artifact | ||
| env: | ||
| REVIEW_COMMENT: ${{ steps.review.outputs.comment }} | ||
| REVIEW_FAILED: ${{ steps.review.outputs.failed }} | ||
| SECRET_BLOCKED: ${{ steps.review.outputs.secret_blocked }} | ||
| run: | | ||
| set -euo pipefail | ||
| mkdir -p gate-artifact | ||
| printf '%s\n' "$REVIEW_COMMENT" > gate-artifact/ob1-review-summary.md | ||
| printf '%s\n' "$REVIEW_COMMENT" >> "$GITHUB_STEP_SUMMARY" | ||
| printf '%s\n' "${{ steps.changed.outputs.files }}" > gate-artifact/changed-files.txt | ||
| printf '%s\n' "${{ steps.changed.outputs.contrib_dirs }}" > gate-artifact/contribution-dirs.txt | ||
| jq -n \ | ||
| --argjson pr_number "${{ github.event.pull_request.number }}" \ | ||
| --arg pr_url "${{ github.event.pull_request.html_url }}" \ | ||
| --arg title "${{ github.event.pull_request.title }}" \ | ||
| --arg head_sha "${{ github.event.pull_request.head.sha }}" \ | ||
| --arg author_login "${{ github.event.pull_request.user.login }}" \ | ||
| --arg author_association "${{ github.event.pull_request.author_association }}" \ | ||
| --arg is_draft "${{ github.event.pull_request.draft }}" \ | ||
| --arg failed "$REVIEW_FAILED" \ | ||
| --arg secret_blocked "$SECRET_BLOCKED" \ | ||
| '{ | ||
| pr_number: $pr_number, | ||
| pr_url: $pr_url, | ||
| title: $title, | ||
| head_sha: $head_sha, | ||
| author_login: $author_login, | ||
| author_association: $author_association, | ||
| is_draft: ($is_draft == "true"), | ||
| failed: ($failed == "true"), | ||
| secret_blocked: ($secret_blocked == "true") | ||
| }' > gate-artifact/ob1-review-context.json | ||
| - name: Upload gate artifact | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: ob1-pr-gate-context | ||
| path: gate-artifact/ | ||
| retention-days: 7 | ||
| - name: Fail if checks failed | ||
| if: steps.review.outputs.failed == 'true' | ||
| run: | | ||
| echo "One or more review checks failed. See the PR comment for details." | ||
| exit 1 | ||