Automate OpenRouter API Key Distribution for External Recipe Contributors (#3198)

Co-authored-by: w. ian douglas <ian.douglas@iandouglas.com>
This commit is contained in:
Ebony Louis
2025-08-28 18:37:09 -04:00
committed by GitHub
parent 1d0c08b3d3
commit 69bc978d00
15 changed files with 2557 additions and 168 deletions
-136
View File
@@ -1,136 +0,0 @@
name: Handle Recipe Submissions
on:
issues:
types: [opened, labeled]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
create-recipe-pr:
if: ${{ github.event.label.name == 'recipe submission' || contains(github.event.issue.labels.*.name, 'recipe submission') }}
runs-on: ubuntu-latest
env:
PROVIDER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install and Configure Goose
run: |
mkdir -p /home/runner/.local/bin
curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh \
| CONFIGURE=false INSTALL_PATH=/home/runner/.local/bin bash
echo "/home/runner/.local/bin" >> $GITHUB_PATH
mkdir -p ~/.config/goose
cat <<EOF > ~/.config/goose/config.yaml
GOOSE_PROVIDER: openrouter
GOOSE_MODEL: "anthropic/claude-3.5-sonnet"
keyring: false
EOF
- name: Extract recipe YAML from issue
id: parse
run: |
ISSUE_BODY=$(jq -r .issue.body "$GITHUB_EVENT_PATH")
RECIPE_YAML=$(echo "$ISSUE_BODY" | awk '/```/,/```/' | sed '1d;$d')
echo "$RECIPE_YAML" > recipe.yaml
AUTHOR="${{ github.event.issue.user.login }}"
if ! grep -q "^author:" recipe.yaml; then
echo -e "\nauthor:\n contact: $AUTHOR" >> recipe.yaml
fi
TITLE=$(yq '.title' recipe.yaml | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-')
echo "branch_name=add-recipe-${TITLE}" >> $GITHUB_OUTPUT
echo "recipe_title=${TITLE}" >> $GITHUB_OUTPUT
- name: Validate recipe.yaml with Goose
id: validate
continue-on-error: true
run: |
OUTPUT=$(goose recipe validate recipe.yaml 2>&1)
echo "$OUTPUT"
{
echo "validation_output<<EOF"
echo "$OUTPUT"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Post validation result to issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
VALIDATION_B64: ${{ steps.validate.outputs.validation_output }}
run: |
if [ "${{ steps.validate.outcome }}" == "failure" ]; then
OUTPUT=$(echo "$VALIDATION_B64" | base64 --decode)
COMMENT="❌ Recipe validation failed:\n\n\`\`\`\n$OUTPUT\n\`\`\`\nPlease fix the above issues and resubmit."
echo -e "$COMMENT" | gh issue comment "$ISSUE_NUMBER"
gh issue close "$ISSUE_NUMBER"
exit 1
else
gh issue comment "$ISSUE_NUMBER" --body "✅ Recipe validated successfully!"
fi
- name: Generate recipeUrl and save updated recipe
run: |
BASE64_ENCODED=$(cat recipe.yaml | base64 | tr -d '\n')
echo "" >> recipe.yaml
echo "recipeUrl: goose://recipe?config=${BASE64_ENCODED}" >> recipe.yaml
- name: Create branch and add file
env:
BRANCH_NAME: ${{ steps.parse.outputs.branch_name }}
run: |
git checkout -b "$BRANCH_NAME"
DEST_DIR="documentation/src/pages/recipes/data/recipes"
mkdir -p "$DEST_DIR"
ID=$(yq '.id' recipe.yaml)
if [ -f "$DEST_DIR/${ID}.yaml" ]; then
echo "❌ Recipe with ID '$ID' already exists. Aborting."
exit 1
fi
cp recipe.yaml "$DEST_DIR/${ID}.yaml"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$DEST_DIR/${ID}.yaml"
git commit -m "Add recipe: ${ID}"
git push origin "$BRANCH_NAME"
- name: Create pull request
id: cpr
uses: peter-evans/create-pull-request@5e5b2916f4b4c9420e5e9b0dc4a6d292d30165d7
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ steps.parse.outputs.branch_name }}
title: "Add recipe: ${{ steps.parse.outputs.recipe_title }}"
body: "This PR adds a new Goose recipe submitted via issue #${{ github.event.issue.number }}."
reviewers: |
EbonyLouis
angiejones
blackgirlbytes
- name: Comment and close issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
PR_URL: ${{ steps.cpr.outputs.pull-request-url }}
run: |
gh issue comment "$ISSUE_NUMBER" --body "🎉 Thanks for submitting your recipe! We've created a [PR]($PR_URL) to add it to the Cookbook."
gh issue close "$ISSUE_NUMBER"
@@ -0,0 +1,394 @@
name: Recipe Security Scan
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'documentation/src/pages/recipes/data/recipes/**'
concurrency:
group: scanner-${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
statuses: write
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Check if recipe files changed in this push
id: recipe_changes
run: |
set -e
echo "🔍 Checking if recipe files were modified in this push..."
# Get the list of changed files in this specific push
if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "synchronize" ]; then
# For synchronize events, check files changed since the previous commit
echo "📝 Synchronize event - checking files changed since previous commit"
CHANGED_FILES=$(git diff --name-only ${{ github.event.before }}..${{ github.event.after }})
else
# For opened/reopened, check all files in the PR
echo "📝 PR opened/reopened - checking all files in PR"
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}..HEAD)
fi
echo "Changed files in this push:"
echo "$CHANGED_FILES"
echo ""
# Check if any recipe files were changed
if echo "$CHANGED_FILES" | grep -q "^documentation/src/pages/recipes/data/recipes/"; then
echo "recipe_files_changed=true" >> "$GITHUB_OUTPUT"
echo "✅ Recipe files were modified in this push - proceeding with scan"
else
echo "recipe_files_changed=false" >> "$GITHUB_OUTPUT"
echo "️ No recipe files were modified in this push - skipping scan"
fi
- name: Ensure jq available
if: steps.recipe_changes.outputs.recipe_files_changed == 'true'
run: sudo apt-get update && sudo apt-get install -y jq
- name: Find recipe files in PR
id: find_recipes
if: steps.recipe_changes.outputs.recipe_files_changed == 'true'
run: |
set -e
echo "Looking for recipe files in PR..."
# Find all .yaml/.yml files in the recipes directory
RECIPE_FILES=$(find documentation/src/pages/recipes/data/recipes/ -name "*.yaml" -o -name "*.yml" 2>/dev/null || true)
if [ -z "$RECIPE_FILES" ]; then
echo "No recipe files found in PR"
echo "has_recipes=false" >> "$GITHUB_OUTPUT"
echo "recipe_count=0" >> "$GITHUB_OUTPUT"
else
echo "Found recipe files:"
echo "$RECIPE_FILES"
RECIPE_COUNT=$(echo "$RECIPE_FILES" | wc -l)
echo "has_recipes=true" >> "$GITHUB_OUTPUT"
echo "recipe_count=$RECIPE_COUNT" >> "$GITHUB_OUTPUT"
# Save recipe file paths for later steps
echo "$RECIPE_FILES" > "$RUNNER_TEMP/recipe_files.txt"
fi
- name: Set up Docker Buildx
if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
uses: docker/setup-buildx-action@1583c0f09d26c58c59d25b0eef29792b7ce99d9a
- name: Prune Docker caches
if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
run: |
docker buildx prune -af || true
docker system prune -af || true
- name: Build scanner image (no cache)
if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
env:
DOCKER_BUILDKIT: 1
IMAGE_TAG: ${{ github.sha }}
run: |
docker buildx build \
--pull \
--no-cache \
--load \
--platform linux/amd64 \
-t "recipe-scanner:${IMAGE_TAG}" \
-f recipe-scanner/Dockerfile \
recipe-scanner/
- name: Scan all recipe files
if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
TRAINING_DATA_LOW: ${{ secrets.TRAINING_DATA_LOW }}
TRAINING_DATA_MEDIUM: ${{ secrets.TRAINING_DATA_MEDIUM }}
TRAINING_DATA_EXTREME: ${{ secrets.TRAINING_DATA_EXTREME }}
IMAGE_TAG: ${{ github.sha }}
run: |
set -e
OUT="$RUNNER_TEMP/security-scan"
mkdir -p "$OUT"
# Set permissions for Docker container (scanner user is UID 1000)
sudo chmod -R 777 "$OUT" || true
# Initialize overall scan results
echo '{"scanned_recipes": [], "overall_status": "UNKNOWN", "failed_scans": 0}' > "$OUT/pr_scan_summary.json"
RECIPE_NUM=1
FAILED_SCANS=0
BLOCKED_RECIPES=0
# Scan each recipe file
while IFS= read -r RECIPE_FILE; do
if [ -f "$RECIPE_FILE" ]; then
echo "🔍 Scanning recipe $RECIPE_NUM: $RECIPE_FILE"
# Create output directory for this recipe
RECIPE_OUT="$OUT/recipe-$RECIPE_NUM"
mkdir -p "$RECIPE_OUT"
sudo chmod -R 777 "$RECIPE_OUT" || true
# Run scanner on this recipe with training data
if docker run --rm \
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
-e TRAINING_DATA_LOW="$TRAINING_DATA_LOW" \
-e TRAINING_DATA_MEDIUM="$TRAINING_DATA_MEDIUM" \
-e TRAINING_DATA_EXTREME="$TRAINING_DATA_EXTREME" \
-v "$PWD/$RECIPE_FILE:/input/recipe.yaml:ro" \
-v "$RECIPE_OUT:/output" \
"recipe-scanner:${IMAGE_TAG}" 2>&1 | tee "$RECIPE_OUT/scan-log.txt"; then
echo "✅ Scan completed for recipe $RECIPE_NUM"
# Check scan result
if [ -f "$RECIPE_OUT/scan_status.json" ]; then
STATUS=$(jq -r .status "$RECIPE_OUT/scan_status.json" || echo "UNKNOWN")
RISK_LEVEL=$(jq -r .risk_level "$RECIPE_OUT/scan_status.json" || echo "UNKNOWN")
if [ "$STATUS" = "BLOCKED" ]; then
BLOCKED_RECIPES=$((BLOCKED_RECIPES + 1))
fi
# Check if risk level requires blocking (MEDIUM, HIGH, CRITICAL)
if [ "$RISK_LEVEL" = "MEDIUM" ] || [ "$RISK_LEVEL" = "HIGH" ] || [ "$RISK_LEVEL" = "CRITICAL" ]; then
BLOCKED_RECIPES=$((BLOCKED_RECIPES + 1))
echo "⚠️ Recipe $RECIPE_NUM blocked due to $RISK_LEVEL risk level"
fi
else
echo "⚠️ No scan_status.json found for recipe $RECIPE_NUM"
FAILED_SCANS=$((FAILED_SCANS + 1))
fi
else
echo "❌ Scan failed for recipe $RECIPE_NUM"
FAILED_SCANS=$((FAILED_SCANS + 1))
fi
RECIPE_NUM=$((RECIPE_NUM + 1))
fi
done < "$RUNNER_TEMP/recipe_files.txt"
# Determine overall status
if [ $FAILED_SCANS -gt 0 ]; then
OVERALL_STATUS="SCAN_FAILED"
elif [ $BLOCKED_RECIPES -gt 0 ]; then
OVERALL_STATUS="BLOCKED"
else
OVERALL_STATUS="APPROVED"
fi
# Update summary
jq --arg status "$OVERALL_STATUS" --argjson failed "$FAILED_SCANS" --argjson blocked "$BLOCKED_RECIPES" \
'.overall_status = $status | .failed_scans = $failed | .blocked_recipes = $blocked' \
"$OUT/pr_scan_summary.json" > "$OUT/pr_scan_summary_tmp.json" && \
mv "$OUT/pr_scan_summary_tmp.json" "$OUT/pr_scan_summary.json"
echo "📊 Scan Summary:"
echo "- Total recipes: $((RECIPE_NUM - 1))"
echo "- Failed scans: $FAILED_SCANS"
echo "- Blocked recipes: $BLOCKED_RECIPES"
echo "- Overall status: $OVERALL_STATUS"
- name: Upload scan artifacts
if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
uses: actions/upload-artifact@v4
with:
name: security-scan
path: ${{ runner.temp }}/security-scan/**
if-no-files-found: warn
retention-days: 10
- name: Post scan results to PR
if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
uses: actions/github-script@v7
env:
WORKSPACE: ${{ github.workspace }}
RUNNER_TEMP: ${{ runner.temp }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const path = require('path');
const tempDir = process.env.RUNNER_TEMP;
const outDir = path.join(tempDir, 'security-scan');
// Read PR scan summary
const summaryPath = path.join(outDir, 'pr_scan_summary.json');
let summary = { overall_status: 'UNKNOWN', failed_scans: 0, blocked_recipes: 0 };
try {
if (fs.existsSync(summaryPath)) {
summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
}
} catch (e) {
console.log('Could not read PR scan summary:', e.message);
}
// Build comment based on overall results
let commentLines = ['🔍 **Recipe Security Scan Results**', ''];
if (summary.overall_status === 'APPROVED') {
commentLines.push('✅ **Status: APPROVED** - All recipes passed security scan');
} else if (summary.overall_status === 'BLOCKED') {
commentLines.push('❌ **Status: BLOCKED** - One or more recipes have MEDIUM risk or higher');
commentLines.push('');
commentLines.push('⚠️ **Merge Protection**: This PR cannot be merged until security concerns are addressed.');
commentLines.push('Repository maintainers can override this decision if needed.');
} else if (summary.overall_status === 'SCAN_FAILED') {
commentLines.push('⚠️ **Status: SCAN FAILED** - Technical issues during scanning');
} else {
commentLines.push('❓ **Status: UNKNOWN** - Could not determine scan results');
}
commentLines.push('');
// Add summary stats
const recipeFiles = fs.readdirSync(outDir).filter(name => name.startsWith('recipe-'));
commentLines.push(`📊 **Scan Summary:**`);
commentLines.push(`- Total recipes scanned: ${recipeFiles.length}`);
if (summary.blocked_recipes > 0) {
commentLines.push(`- Blocked recipes: ${summary.blocked_recipes}`);
}
if (summary.failed_scans > 0) {
commentLines.push(`- Failed scans: ${summary.failed_scans}`);
}
// Add individual recipe results
if (recipeFiles.length > 0) {
commentLines.push('', '📋 **Individual Recipe Results:**');
recipeFiles.forEach((recipeDir, index) => {
const recipePath = path.join(outDir, recipeDir);
const statusPath = path.join(recipePath, 'scan_status.json');
let status = 'UNKNOWN';
let risk = 'UNKNOWN';
try {
if (fs.existsSync(statusPath)) {
const statusData = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
status = statusData.status || 'UNKNOWN';
risk = statusData.risk_level || 'UNKNOWN';
}
} catch (e) {
status = 'SCAN_ERROR';
}
const statusEmoji = status === 'APPROVED' ? '✅' :
status === 'BLOCKED' ? '❌' :
status === 'ALLOWED_WITH_WARNINGS' ? '⚠️' : '❓';
commentLines.push(`${statusEmoji} Recipe ${index + 1}: ${status} (${risk} risk)`);
});
}
commentLines.push('', `🔗 **View detailed scan results in the [workflow artifacts](https://github.com/${context.repo.owner}/${context.repo.repo}/actions).**`);
const comment = commentLines.join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: comment
});
- name: Set GitHub status check
if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
uses: actions/github-script@v7
env:
RUNNER_TEMP: ${{ runner.temp }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const path = require('path');
const tempDir = process.env.RUNNER_TEMP;
const outDir = path.join(tempDir, 'security-scan');
// Read PR scan summary
const summaryPath = path.join(outDir, 'pr_scan_summary.json');
let summary = { overall_status: 'UNKNOWN' };
try {
if (fs.existsSync(summaryPath)) {
summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
}
} catch (e) {
console.log('Could not read PR scan summary:', e.message);
}
// Determine GitHub status
let state, description;
if (summary.overall_status === 'APPROVED') {
state = 'success';
description = 'All recipes passed security scan';
} else if (summary.overall_status === 'BLOCKED') {
state = 'failure';
description = 'One or more recipes failed security scan';
} else if (summary.overall_status === 'SCAN_FAILED') {
state = 'error';
description = 'Technical issues during security scan';
} else {
state = 'error';
description = 'Could not determine scan results';
}
// Set status check
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.payload.pull_request.head.sha,
state: state,
target_url: `${context.payload.pull_request.html_url}/checks`,
description: description,
context: 'security-scan/recipe-scanner'
});
- name: Final scan result
if: always()
run: |
# Check if recipe files were changed in this push
if [ "${{ steps.recipe_changes.outputs.recipe_files_changed }}" = "false" ]; then
# No recipe files were modified in this push - scan skipped
exit 0
fi
OUT="$RUNNER_TEMP/security-scan"
SUMMARY_FILE="$OUT/pr_scan_summary.json"
if [ -f "$SUMMARY_FILE" ]; then
OVERALL_STATUS=$(jq -r .overall_status "$SUMMARY_FILE")
echo "📊 Final scan result: $OVERALL_STATUS"
if [ "$OVERALL_STATUS" = "BLOCKED" ]; then
echo "::error::One or more recipes have MEDIUM risk or higher - PR merge blocked"
echo "Repository maintainers can override this decision if needed"
exit 1
elif [ "$OVERALL_STATUS" = "APPROVED" ]; then
echo "::notice::All recipes APPROVED by security scan"
else
echo "::error::Scan did not complete successfully - check artifacts for details"
exit 1
fi
else
echo "::error::No scan summary found - scan may have failed completely"
exit 1
fi
-30
View File
@@ -1,30 +0,0 @@
name: Auto-reply to Recipe Submissions
on:
issues:
types: [opened]
jobs:
thank-you-comment:
if: contains(github.event.issue.title, '[Recipe]')
runs-on: ubuntu-latest
steps:
- name: Add thank-you comment
uses: actions/github-script@v7
with:
script: |
const commentBody = [
"🎉 Thanks for submitting your Goose recipe to the Cookbook!",
"",
"We appreciate you sharing your workflow with the community — our team will review your submission soon.",
"If accepted, itll be added to the [Goose Recipes Cookbook](https://block.github.io/goose/recipes) and youll receive LLM credits as a thank-you!",
"",
"Stay tuned — and keep those recipes coming 🧑‍🍳🔥"
].join('\n');
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
+32
View File
@@ -0,0 +1,32 @@
name: Send API Key on PR Merge
on:
pull_request:
types: [closed]
paths:
- 'documentation/src/pages/recipes/data/recipes/**'
jobs:
send-api-key:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies and run email script
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_API_URL: ${{ github.event.pull_request.url }}
PROVISIONING_API_KEY: ${{ secrets.PROVISIONING_API_KEY }}
EMAIL_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
run: |
pip install requests sendgrid email-validator
python .github/scripts/send_key.py
+200
View File
@@ -0,0 +1,200 @@
name: Validate Recipe PR
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'documentation/src/pages/recipes/data/recipes/**'
permissions:
contents: read
pull-requests: write
jobs:
validate-recipe:
runs-on: ubuntu-latest
env:
PROVIDER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install and Configure Goose
run: |
mkdir -p /home/runner/.local/bin
curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh \
| CONFIGURE=false INSTALL_PATH=/home/runner/.local/bin bash
echo "/home/runner/.local/bin" >> $GITHUB_PATH
mkdir -p ~/.config/goose
cat <<EOF > ~/.config/goose/config.yaml
GOOSE_PROVIDER: openrouter
GOOSE_MODEL: "anthropic/claude-3.5-sonnet"
keyring: false
EOF
- name: Find and validate recipe files
id: validate
run: |
echo "🔍 Looking for recipe files..."
RECIPE_FILES=$(find documentation/src/pages/recipes/data/recipes/ -name "*.yaml" -o -name "*.yml" 2>/dev/null || true)
if [ -z "$RECIPE_FILES" ]; then
echo "❌ No recipe files found in the correct location!"
echo "📁 Please add your recipe to: documentation/src/pages/recipes/data/recipes/"
echo "validation_status=no_files" >> $GITHUB_OUTPUT
exit 1
fi
echo "Found recipe files:"
echo "$RECIPE_FILES"
ALL_VALID=true
VALIDATION_OUTPUT=""
# First pass: Basic YAML validation
while IFS= read -r RECIPE_FILE; do
if [ -f "$RECIPE_FILE" ]; then
echo "🔍 Validating: $RECIPE_FILE"
if OUTPUT=$(goose recipe validate "$RECIPE_FILE" 2>&1); then
echo "✅ Valid: $RECIPE_FILE"
VALIDATION_OUTPUT="${VALIDATION_OUTPUT}✅ $RECIPE_FILE: VALID\n"
else
echo "❌ Invalid: $RECIPE_FILE"
echo "$OUTPUT"
VALIDATION_OUTPUT="${VALIDATION_OUTPUT}❌ $RECIPE_FILE: INVALID\n\`\`\`\n$OUTPUT\n\`\`\`\n"
ALL_VALID=false
fi
fi
done <<< "$RECIPE_FILES"
# Second pass: Check for duplicate filenames
if [ "$ALL_VALID" = true ]; then
echo "🔍 Checking for duplicate filenames..."
# Check for duplicate filenames first
SEEN_FILENAMES=""
while IFS= read -r RECIPE_FILE; do
if [ -f "$RECIPE_FILE" ]; then
FILENAME=$(basename "$RECIPE_FILE" .yaml)
FILENAME=$(basename "$FILENAME" .yml)
echo "📋 Checking filename: '$FILENAME'"
# Check if we've seen this filename before in this PR
if echo "$SEEN_FILENAMES" | grep -q "^$FILENAME$"; then
echo "❌ Duplicate filename '$FILENAME' found in this PR"
VALIDATION_OUTPUT="${VALIDATION_OUTPUT}❌ Duplicate filename '$FILENAME' found in this PR\n"
ALL_VALID=false
else
SEEN_FILENAMES="$SEEN_FILENAMES\n$FILENAME"
fi
# Check if this is a new file or an update to existing file
# Get list of changed files in this PR compared to base branch
CHANGED_FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }}...HEAD | grep "^$RECIPE_FILE$" || true)
EXISTING_FILES=$(find documentation/src/pages/recipes/data/recipes/ -name "$FILENAME.yaml" -o -name "$FILENAME.yml" | grep -v "^$RECIPE_FILE$" || true)
if [ -n "$EXISTING_FILES" ] && [ -z "$CHANGED_FILES" ]; then
# File exists in repo but is not being modified - this is a new duplicate
echo "❌ Recipe filename '$FILENAME' already exists:"
echo "$EXISTING_FILES"
VALIDATION_OUTPUT="${VALIDATION_OUTPUT}❌ $RECIPE_FILE: Filename '$FILENAME' already exists in: $EXISTING_FILES\n"
ALL_VALID=false
elif [ -n "$EXISTING_FILES" ] && [ -n "$CHANGED_FILES" ]; then
# File exists and is being modified - this is an update
echo "✅ Updating existing recipe: '$FILENAME'"
else
# File doesn't exist - this is a new recipe
echo "✅ New recipe filename '$FILENAME' is unique"
fi
echo "✅ Filename '$FILENAME' validation complete"
fi
done <<< "$RECIPE_FILES"
fi
# Save validation output for use in comment
echo "$VALIDATION_OUTPUT" > /tmp/validation_output.txt
if [ "$ALL_VALID" = true ]; then
echo "validation_status=valid" >> $GITHUB_OUTPUT
else
echo "validation_status=invalid" >> $GITHUB_OUTPUT
fi
- name: Comment validation results
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const status = '${{ steps.validate.outputs.validation_status }}';
let comment;
if (status === 'no_files') {
comment = `❌ **Recipe Validation Failed**
No recipe files found in the correct location!
📁 **Please add your recipe to**: \`documentation/src/pages/recipes/data/recipes/your-recipe-id.yaml\`
**Example**: If your recipe ID is \`web-scraper\`, create:
\`documentation/src/pages/recipes/data/recipes/web-scraper.yaml\``;
} else if (status === 'valid') {
comment = `✅ **Recipe Validation Passed**
Your recipe(s) are valid and ready for review!
🔍 **Next Steps**:
1. Our team will review your recipe
2. If approved, we'll run a security scan
3. Once merged, you'll receive $10 in OpenRouter credits (if email provided)
Thanks for contributing to the Goose Recipe Cookbook! 🎉`;
} else {
// Read validation details from file
let validationDetails = '';
try {
validationDetails = fs.readFileSync('/tmp/validation_output.txt', 'utf8');
} catch (e) {
validationDetails = 'See workflow logs for details.';
}
comment = `❌ **Recipe Validation Failed**
Please fix the validation errors and push your changes:
${validationDetails}
📚 Check our [Recipe Guide](https://block.github.io/goose/recipes) for help with the correct format.`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: comment
});
- name: Set validation status
if: always()
env:
VALIDATION_STATUS: ${{ steps.validate.outputs.validation_status }}
run: |
if [ "$VALIDATION_STATUS" = "valid" ]; then
echo "✅ All recipes are valid"
exit 0
else
echo "❌ Recipe validation failed"
exit 1
fi