Skip to content

[Sync] Update project files from source repository (8e6bb89) #16

[Sync] Update project files from source repository (8e6bb89)

[Sync] Update project files from source repository (8e6bb89) #16

# ------------------------------------------------------------------------------------
# Pull Request Management for Forks Workflow
#
# Purpose: Automate labeling, assignment, and welcoming of pull requests for forked PRs.
#
# Configuration: All settings are loaded from .env.base and .env.custom files for
# centralized management across all workflows.
#
# Triggers: Pull request events (opened, reopened, ready for review, closed, synchronize)
#
# Features:
# - Automatic labeling based on branch prefix and PR title
# - Default assignee management
# - Welcome messages for first-time contributors
# - PR size analysis and labeling
# - Cache cleanup on PR close
# - Branch deletion after merge
#
# Maintainer: @mrz1836
#
# ════════════════════════════════════════════════════════════════════════════════
# 🔒 SECURITY MODEL - Two-Workflow Pattern for Safe Fork PR Handling
# ════════════════════════════════════════════════════════════════════════════════
#
# This workflow implements the RECOMMENDED security pattern for handling fork PRs
# as documented in GitHub Security Best Practices (githubactions:S7631).
#
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ WHY pull_request_target IS SAFE HERE: │
# │ │
# │ ✅ Uses pull_request_target trigger for write permissions │
# │ (Required for: labels, comments, assignees) │
# │ │
# │ ✅ CRITICAL: Only checks out BASE branch code, NEVER PR head │
# │ (Prevents malicious code execution from untrusted forks) │
# │ │
# │ ✅ Fork detection uses full_name comparison for accuracy │
# │ (Not owner.login which fails for org members) │
# │ │
# │ ✅ All code execution happens from trusted base repository │
# │ (No code from PR is ever executed) │
# │ │
# │ ✅ No secrets exposed to fork PRs (GITHUB_TOKEN only) │
# │ (No custom secrets accessible to malicious actors) │
# │ │
# │ ✅ Sparse checkout minimizes attack surface │
# │ (Only config files checked out, no executable code) │
# │ │
# │ ✅ Least-privilege permissions model │
# │ (Jobs get elevated permissions only where absolutely needed) │
# └─────────────────────────────────────────────────────────────────────────────┘
#
# SECURITY PATTERN: Two-Workflow Approach
# ├─ pull-request-management.yml → Same-repo PRs (uses pull_request)
# └─ pull-request-management-fork.yml → Fork PRs (uses pull_request_target)
#
# WHAT COULD GO WRONG (and how we prevent it):
# ❌ Malicious fork creates PR with code that steals secrets
# ✅ PREVENTED: We never checkout or execute PR code
#
# ❌ Attacker modifies workflow files in their fork
# ✅ PREVENTED: pull_request_target runs base repo workflow only
#
# ❌ Malicious code in PR tries to access repository secrets
# ✅ PREVENTED: Only GITHUB_TOKEN exposed, no custom secrets
#
# ❌ Code injection via PR title/description into workflow
# ✅ PREVENTED: All user input properly escaped in GitHub Actions
#
# SECURITY SCANNERS:
# - GitHub Security: May flag pull_request_target + checkout (FALSE POSITIVE)
# - Semgrep: May flag dangerous-checkout pattern (FALSE POSITIVE)
# - Checkov: May flag CKV_GHA_3 (FALSE POSITIVE)
#
# These are FALSE POSITIVES because:
# 1. We explicitly checkout base branch, not PR head
# 2. This is the RECOMMENDED pattern per GitHub docs
# 3. All security controls are properly implemented
#
# REFERENCES:
# - GitHub Security Advisory: githubactions:S7631
# - GitHub Docs: Keeping your GitHub Actions and workflows secure
# - OWASP: CI/CD Security Top 10
#
# ════════════════════════════════════════════════════════════════════════════════
#
# ------------------------------------------------------------------------------------
name: PR Management (Forks)
# --------------------------------------------------------------------
# Trigger Configuration
# --------------------------------------------------------------------
on:
pull_request_target:
types: [opened, reopened, ready_for_review, closed, synchronize]
# Least privilege at the workflow level; jobs get bumps as needed
permissions:
contents: read
# --------------------------------------------------------------------
# Concurrency Control
# --------------------------------------------------------------------
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}-fork
cancel-in-progress: true
# --------------------------------------------------------------------
# Environment Variables
# --------------------------------------------------------------------
# Note: Configuration variables are loaded from .env.base and .env.custom files
jobs:
# ------------------------------------------------------------
# Load env from the BASE repo only (safe) for centralized config
# ------------------------------------------------------------
load-env:
name: 🌍 Load Environment (Base Repo)
runs-on: ubuntu-latest
# No write perms here
permissions:
contents: read
outputs:
env-json: ${{ steps.load-env.outputs.env-json }}
steps:
# ┌─────────────────────────────────────────────────────────────────────┐
# │ SECURITY SCANNERS: This checkout is SAFE despite pull_request_target│
# │ │
# │ Justification: │
# │ - Only checks out TRUSTED base branch (ref: github.base_ref) │
# │ - NEVER checks out PR head code from untrusted fork │
# │ - Implements recommended two-workflow security pattern │
# │ - Uses sparse checkout (minimal attack surface) │
# │ - No executable code from PR is ever run │
# │ │
# │ Pattern: Two-workflow security model (see SECURITY.md) │
# │ References: githubactions:S7631, semgrep:github-actions-checkout │
# └─────────────────────────────────────────────────────────────────────┘
# semgrep:ignore github-actions-dangerous-checkout
# codeql:ignore GH001
# checkov:skip=CKV_GHA_3:Base branch checkout is intentional and safe
- name: 📥 Checkout base repo (sparse)
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🔒 CRITICAL SECURITY CONTROL: Base Branch Checkout Only
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# This workflow uses pull_request_target for write permissions BUT
# ONLY checks out the trusted base branch code - NEVER PR head code.
#
# WHY THIS IS SAFE:
# - ref parameter explicitly set to base branch (github.base_ref)
# - Malicious fork PRs cannot inject code into this workflow
# - All code execution happens from trusted repository only
# - Sparse checkout limits to config files only (no executables)
#
# SECURITY MODEL:
# - pull_request_target = write permissions needed for labels/comments
# - Base branch checkout = prevents malicious code execution
# - This is the RECOMMENDED pattern for fork PR automation
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ref: ${{ github.base_ref }}
fetch-depth: 1
sparse-checkout: |
.github/.env.base
.github/.env.custom
.github/actions/load-env
- name: 🌍 Load environment variables
id: load-env
uses: ./.github/actions/load-env
# ------------------------------------------------------------
# Detect if this is truly a fork PR with proper null handling
# ------------------------------------------------------------
detect-fork:
name: 🔍 Detect Fork PR
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
is-fork: ${{ steps.detection.outputs.is-fork }}
steps:
- name: 🔍 Fork detection with null checks
id: detection
env:
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name || '' }}
BASE_REPO: ${{ github.repository }}
run: |
echo "════════════════════════════════════════════════════════════════"
echo "🔍 Fork Detection Debug"
echo "════════════════════════════════════════════════════════════════"
echo " PR Head Repo: '${PR_HEAD_REPO}'"
echo " Base Repo: '${BASE_REPO}'"
echo " Event: ${{ github.event_name }}"
echo "════════════════════════════════════════════════════════════════"
# Check if this is a fork PR with proper null/empty handling
# A fork PR is when:
# 1. PR_HEAD_REPO is not empty (not null/undefined)
# 2. PR_HEAD_REPO != BASE_REPO (different repositories)
if [[ -n "$PR_HEAD_REPO" ]] && [[ "$PR_HEAD_REPO" != "$BASE_REPO" ]]; then
echo "🚨 FORK PR DETECTED"
echo "is-fork=true" >> $GITHUB_OUTPUT
else
echo "✅ NOT A FORK PR (Same repository or invalid head repo)"
echo "is-fork=false" >> $GITHUB_OUTPUT
fi
echo "════════════════════════════════════════════════════════════════"
# ------------------------------------------------------------
# Fork detector + labeller/commenter/assignee
# ------------------------------------------------------------
handle-fork:
name: 🏷️ Label/Assign/Comment (Fork PR)
needs: [load-env, detect-fork]
runs-on: ubuntu-latest
# Only run for fork PRs (different repository) - using detection output
if: needs.detect-fork.outputs.is-fork == 'true'
permissions:
# We need to WRITE to PR for labels/comments/assignees
pull-requests: write
issues: write
contents: read
steps:
- name: 🔧 Extract config
id: cfg
env:
ENV_JSON: ${{ needs.load-env.outputs.env-json }}
run: |
# pull minimal config, with sensible fallbacks
DEFAULT_ASSIGNEE=$(echo "$ENV_JSON" | jq -r '.PR_MANAGEMENT_DEFAULT_ASSIGNEE // ""')
SKIP_BOT_USERS=$(echo "$ENV_JSON" | jq -r '.PR_MANAGEMENT_SKIP_BOT_USERS // ""')
FORK_LABEL=$(echo "$ENV_JSON" | jq -r '.PR_MANAGEMENT_FORK_LABEL // "fork-pr"')
TRIAGE_LABEL=$(echo "$ENV_JSON" | jq -r '.PR_MANAGEMENT_TRIAGE_LABEL // "requires-manual-review"')
WELCOME_FORKS=$(echo "$ENV_JSON" | jq -r '.PR_MANAGEMENT_WELCOME_FORKS // "true"')
echo "DEFAULT_ASSIGNEE=$DEFAULT_ASSIGNEE" >> "$GITHUB_ENV"
echo "SKIP_BOT_USERS=$SKIP_BOT_USERS" >> "$GITHUB_ENV"
echo "FORK_LABEL=$FORK_LABEL" >> "$GITHUB_ENV"
echo "TRIAGE_LABEL=$TRIAGE_LABEL" >> "$GITHUB_ENV"
echo "WELCOME_FORKS=$WELCOME_FORKS" >> "$GITHUB_ENV"
- name: 🏷️ Add fork + triage labels
id: labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = context.payload.pull_request;
const prNumber = pr.number;
const author = pr.user.login;
// Skip bots if configured
const skip = (process.env.SKIP_BOT_USERS || '')
.split(',').map(s => s.trim()).filter(Boolean);
if (skip.includes(author)) {
core.info(`Skipping labels for bot user: ${author}`);
return;
}
const ensureLabels = async (names) => {
// create missing labels lazily (safe colors)
for (const name of names) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner, repo: context.repo.repo, name
});
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color: name === process.env.TRIAGE_LABEL ? "d876e3" : "ededed",
});
core.info(`Created missing label: ${name}`);
} else {
throw e;
}
}
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: [process.env.FORK_LABEL, process.env.TRIAGE_LABEL]
});
};
await ensureLabels([process.env.FORK_LABEL, process.env.TRIAGE_LABEL]);
- name: 👤 Assign default assignee (optional)
id: assign
if: env.DEFAULT_ASSIGNEE != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = context.payload.pull_request;
const author = pr.user.login;
const skip = (process.env.SKIP_BOT_USERS || '')
.split(',').map(s => s.trim()).filter(Boolean);
if (skip.includes(author)) {
core.info(`Skipping assignment for bot user: ${author}`);
return;
}
if ((pr.assignees || []).length === 0) {
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
assignees: [process.env.DEFAULT_ASSIGNEE],
});
core.info(`Assigned to @${process.env.DEFAULT_ASSIGNEE}`);
} else {
core.info('PR already has assignees; skipping.');
}
- name: 💬 Comment notice for fork PR
id: comment
if: env.WELCOME_FORKS == 'true' && github.event.action == 'opened'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = context.payload.pull_request;
const author = pr.user.login;
const repoName = context.repo.repo;
const repoOwner = context.repo.owner;
const body = `## 👋 Thanks, @${author}!
This pull request comes from a **fork**. For security, our CI runs in a restricted mode.
A maintainer will triage this shortly and run any additional checks as needed.
- 🏷️ Labeled: \`${process.env.FORK_LABEL}\`, \`${process.env.TRIAGE_LABEL}\`
- 👀 We'll review and follow up here if anything else is needed.
Thanks for contributing to **${repoOwner}/${repoName}**! 🚀
<!-- fork-welcome-v1 -->`;
// Check for existing welcome comment to avoid duplicates
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100
});
const welcomeExists = comments.some(comment =>
comment.body.includes('<!-- fork-welcome-v1 -->') &&
comment.user.login === 'github-actions[bot]'
);
if (!welcomeExists) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body
});
core.info(`✅ Posted welcome comment for fork PR from @${author}`);
} else {
core.info(`ℹ️ Welcome comment already exists, skipping duplicate`);
}
# ------------------------------------------------------------
# Clean Runner Cache (on PR close)
# ------------------------------------------------------------
clean-cache:
name: 🧹 Clean Runner Cache
needs: [load-env, detect-fork]
runs-on: ubuntu-latest
permissions:
actions: write # Required: Delete GitHub Actions caches for closed PRs
contents: read # Read repository content for cache management
if: github.event.action == 'closed' && needs.detect-fork.outputs.is-fork == 'true'
outputs:
caches-cleaned: ${{ steps.clean.outputs.caches-cleaned }}
steps:
# --------------------------------------------------------------------
# Extract configuration from env-json
# --------------------------------------------------------------------
- name: 🔧 Extract configuration
id: config
env:
ENV_JSON: ${{ needs.load-env.outputs.env-json }}
run: |
echo "📋 Extracting PR management configuration from environment..."
# Extract all needed variables
CLEAN_CACHE=$(echo "$ENV_JSON" | jq -r '.PR_MANAGEMENT_CLEAN_CACHE_ON_CLOSE // "true"')
# Set as environment variables for all subsequent steps
echo "CLEAN_CACHE=$CLEAN_CACHE" >> $GITHUB_ENV
# Log configuration
echo "🔍 Configuration loaded:"
echo " 🧹 Clean cache on close: $CLEAN_CACHE"
# --------------------------------------------------------------------
# Clean up caches associated with the PR
# --------------------------------------------------------------------
- name: 🧹 Cleanup caches
id: clean
if: env.CLEAN_CACHE == 'true'
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
echo "🧹 Cleaning up caches for fork PR #$PR_NUMBER..."
echo "════════════════════════════════════════════════════════════════"
# Fetch the list of cache keys for this PR
echo "📋 Fetching cache list for PR #$PR_NUMBER..."
# Get all caches and filter for this PR (checking multiple possible refs)
allCaches=$(gh cache list --limit 100 --json id,key,ref)
# Debug: Show what refs we're looking for
echo "🔍 Looking for caches with refs:"
echo " - refs/pull/$PR_NUMBER/merge"
echo " - refs/pull/$PR_NUMBER/head"
echo " - refs/heads/$PR_HEAD_REF"
# Filter caches that belong to this PR (multiple possible refs)
cacheKeysForPR=$(echo "$allCaches" | jq -r --arg pr "$PR_NUMBER" --arg branch "$PR_HEAD_REF" \
'.[] | select(
.ref == "refs/pull/\($pr)/merge" or
.ref == "refs/pull/\($pr)/head" or
.ref == "refs/heads/\($branch)"
) | .id')
# Count caches - handle empty results properly
if [ -z "$cacheKeysForPR" ]; then
cacheCount=0
else
cacheCount=$(echo "$cacheKeysForPR" | wc -l | tr -d ' ')
fi
if [ "$cacheCount" -eq "0" ]; then
echo "ℹ️ No caches found for this PR"
echo "caches-cleaned=0" >> $GITHUB_OUTPUT
exit 0
fi
echo "🗑️ Found $cacheCount cache(s) to clean"
# Setting this to not fail the workflow while deleting cache keys
set +e
cleanedCount=0
# Delete each cache
for cacheKey in $cacheKeysForPR; do
if gh cache delete "$cacheKey"; then
echo " ✅ Deleted cache: $cacheKey"
((cleanedCount++))
else
echo " ⚠️ Failed to delete cache: $cacheKey"
fi
done
echo "════════════════════════════════════════════════════════════════"
echo "✅ Cleaned $cleanedCount out of $cacheCount cache(s)"
echo "caches-cleaned=$cleanedCount" >> $GITHUB_OUTPUT
# ------------------------------------------------------------
# Human-friendly run summary
# ------------------------------------------------------------
summary:
name: 📊 Summary
runs-on: ubuntu-latest
if: always()
needs: [load-env, detect-fork, handle-fork, clean-cache]
steps:
- name: 📄 Write summary
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_ACTION: ${{ github.event.action }}
IS_FORK: ${{ needs.detect-fork.outputs.is-fork }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name || '' }}
BASE_REPO: ${{ github.repository }}
run: |
echo "# 🔧 Fork PR Management Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**PR:** #$PR_NUMBER — $PR_TITLE" >> $GITHUB_STEP_SUMMARY
echo "**Author:** @$PR_AUTHOR" >> $GITHUB_STEP_SUMMARY
echo "**Action:** $PR_ACTION" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Show fork detection results
echo "## 🔍 Fork Detection" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| PR Head Repo | \`$PR_HEAD_REPO\` |" >> $GITHUB_STEP_SUMMARY
echo "| Base Repo | \`$BASE_REPO\` |" >> $GITHUB_STEP_SUMMARY
echo "| Is Fork PR? | **$IS_FORK** |" >> $GITHUB_STEP_SUMMARY
if [ "$IS_FORK" = "true" ]; then
echo "| Status | ✅ Fork PR - Handled with restricted permissions |" >> $GITHUB_STEP_SUMMARY
else
echo "| Status | ℹ️ **NOT a fork PR** - This workflow should not have processed this PR |" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
# Show cache cleanup results if PR was closed
if [ "$PR_ACTION" = "closed" ]; then
echo "## 🧹 Cleanup Actions" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Action | Result |" >> $GITHUB_STEP_SUMMARY
echo "|--------|--------|" >> $GITHUB_STEP_SUMMARY
# Cache cleanup
if [ "${{ needs.clean-cache.result }}" = "success" ]; then
CACHES="${{ needs.clean-cache.outputs.caches-cleaned }}"
echo "| 🧹 Cache Cleanup | $CACHES cache(s) cleaned |" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo "---" >> $GITHUB_STEP_SUMMARY
echo "**Security:** This workflow used **pull_request_target** and did **not** check out or execute the PR's code." >> $GITHUB_STEP_SUMMARY