Skip to content

Merge pull request #261 from yuens1002/contrib/yuens1002/per-request-mcp #36

Merge pull request #261 from yuens1002/contrib/yuens1002/per-request-mcp

Merge pull request #261 from yuens1002/contrib/yuens1002/per-request-mcp #36

Workflow file for this run

name: OB1 PR Gate

Check failure on line 1 in .github/workflows/ob1-gate-v2.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/ob1-gate-v2.yml

Invalid workflow file

(Line: 56, Col: 14): Exceeded max expression length 21000
# 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