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:
@@ -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
|
||||
@@ -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, it’ll be added to the [Goose Recipes Cookbook](https://block.github.io/goose/recipes) and you’ll 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
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user