diff --git a/.changeset/patch-rate-limit-programmatic-events.md b/.changeset/patch-rate-limit-programmatic-events.md new file mode 100644 index 0000000000..cd2d5ac641 --- /dev/null +++ b/.changeset/patch-rate-limit-programmatic-events.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Protect workflows from abusive programmatic triggers by adding configurable per-user, per-workflow rate limiting with automatic event inference, filtered cancellations, and window controls. diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index 63161d0760..08cc0136f3 100644 --- a/.github/workflows/ai-moderator.lock.yml +++ b/.github/workflows/ai-moderator.lock.yml @@ -24,7 +24,7 @@ # Imports: # - shared/mood.md # -# frontmatter-hash: 8100c57c738a6edcbae1a4445a8370c200ee4dabe1da348a6ab75c64c7e57e47 +# frontmatter-hash: ae515f3b3a547d9b86e5347378b5d34ce798ff0eb460bd6c912305e358b305c1 name: "AI Moderator" "on": @@ -991,9 +991,10 @@ jobs: pre_activation: runs-on: ubuntu-slim permissions: + actions: read contents: read outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -1018,6 +1019,20 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); await main(); + - name: Check user rate limit + id: check_rate_limit + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_RATE_LIMIT_MAX: "5" + GH_AW_RATE_LIMIT_WINDOW: "60" + GH_AW_RATE_LIMIT_EVENTS: "workflow_dispatch,issues,issue_comment" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_rate_limit.cjs'); + await main(); safe_outputs: needs: diff --git a/.github/workflows/ai-moderator.md b/.github/workflows/ai-moderator.md index 944104a304..46850ac9e0 100644 --- a/.github/workflows/ai-moderator.md +++ b/.github/workflows/ai-moderator.md @@ -14,6 +14,9 @@ on: description: 'Issue URL to moderate (e.g., https://github.com/owner/repo/issues/123)' required: true type: string +rate-limit: + max: 5 + window: 60 engine: id: copilot model: gpt-5.1-codex-mini diff --git a/.github/workflows/auto-triage-issues.lock.yml b/.github/workflows/auto-triage-issues.lock.yml index 2f50ac869b..12a64ec04d 100644 --- a/.github/workflows/auto-triage-issues.lock.yml +++ b/.github/workflows/auto-triage-issues.lock.yml @@ -26,7 +26,7 @@ # - shared/mood.md # - shared/reporting.md # -# frontmatter-hash: e45aaf413ad8d0b82e2190a8aab5711d2c9842f2a68dae9334a386ceae611752 +# frontmatter-hash: 9c6fbb9920281fff3373c0db01b6a5e5fe93b637d2902977763c0634733725be name: "Auto-Triage Issues" "on": @@ -1049,9 +1049,10 @@ jobs: pre_activation: runs-on: ubuntu-slim permissions: + actions: read contents: read outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -1075,6 +1076,20 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); await main(); + - name: Check user rate limit + id: check_rate_limit + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_RATE_LIMIT_MAX: "5" + GH_AW_RATE_LIMIT_WINDOW: "60" + GH_AW_RATE_LIMIT_EVENTS: "issues,workflow_dispatch" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_rate_limit.cjs'); + await main(); safe_outputs: needs: diff --git a/.github/workflows/auto-triage-issues.md b/.github/workflows/auto-triage-issues.md index b4a63cff50..89bbdaed52 100644 --- a/.github/workflows/auto-triage-issues.md +++ b/.github/workflows/auto-triage-issues.md @@ -5,6 +5,9 @@ on: issues: types: [opened, edited] schedule: every 6h +rate-limit: + max: 5 + window: 60 permissions: contents: read issues: read diff --git a/.github/workflows/example-custom-error-patterns.lock.yml b/.github/workflows/example-custom-error-patterns.lock.yml index d9d2f45f52..890c0df337 100644 --- a/.github/workflows/example-custom-error-patterns.lock.yml +++ b/.github/workflows/example-custom-error-patterns.lock.yml @@ -24,7 +24,7 @@ # Imports: # - shared/mood.md # -# frontmatter-hash: 8777360f3ce21656e3f8bce4d00fdd94f20a9295969d3172977e453a086a06fc +# frontmatter-hash: 5fb42e71fdea5fafa3ea342aac01e6373a703bf85c191fe80ab16d873ee25131 name: "Example: Custom Error Patterns" "on": @@ -475,9 +475,10 @@ jobs: pre_activation: runs-on: ubuntu-slim permissions: + actions: read contents: read outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -501,4 +502,18 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); await main(); + - name: Check user rate limit + id: check_rate_limit + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_RATE_LIMIT_MAX: "5" + GH_AW_RATE_LIMIT_WINDOW: "60" + GH_AW_RATE_LIMIT_EVENTS: "issues" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_rate_limit.cjs'); + await main(); diff --git a/.github/workflows/example-custom-error-patterns.md b/.github/workflows/example-custom-error-patterns.md index e170273716..4d45390e49 100644 --- a/.github/workflows/example-custom-error-patterns.md +++ b/.github/workflows/example-custom-error-patterns.md @@ -2,7 +2,9 @@ on: issues: types: [opened] - +rate-limit: + max: 5 + window: 60 permissions: contents: read issues: read diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index 0f4e561b21..da9ee4901a 100644 --- a/.github/workflows/workflow-generator.lock.yml +++ b/.github/workflows/workflow-generator.lock.yml @@ -25,7 +25,7 @@ # Imports: # - shared/mood.md # -# frontmatter-hash: 2c2bf31df99a83c301214912d0cbe9e07f12cd4a8f188b2852bc94dc403d40d2 +# frontmatter-hash: 1bf8a53ac78955ad6bc31224322cd512e946710820a359cc08613e147865408c name: "Workflow Generator" "on": @@ -1101,12 +1101,13 @@ jobs: if: startsWith(github.event.issue.title, '[Workflow]') runs-on: ubuntu-slim permissions: + actions: read contents: read discussions: write issues: write pull-requests: write outputs: - activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -1143,6 +1144,20 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); await main(); + - name: Check user rate limit + id: check_rate_limit + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_RATE_LIMIT_MAX: "5" + GH_AW_RATE_LIMIT_WINDOW: "60" + GH_AW_RATE_LIMIT_EVENTS: "issues" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_rate_limit.cjs'); + await main(); safe_outputs: needs: diff --git a/.github/workflows/workflow-generator.md b/.github/workflows/workflow-generator.md index 9d48a0a783..c6ca7df0cf 100644 --- a/.github/workflows/workflow-generator.md +++ b/.github/workflows/workflow-generator.md @@ -5,6 +5,9 @@ on: types: [opened] lock-for-agent: true reaction: "eyes" +rate-limit: + max: 5 + window: 60 permissions: contents: read issues: read diff --git a/actions/setup/js/check_rate_limit.cjs b/actions/setup/js/check_rate_limit.cjs new file mode 100644 index 0000000000..67773b9186 --- /dev/null +++ b/actions/setup/js/check_rate_limit.cjs @@ -0,0 +1,231 @@ +// @ts-check +/// + +/** + * Rate limit check for per-user per-workflow triggers + * Prevents users from triggering workflows too frequently + */ + +async function main() { + const actor = context.actor; + const owner = context.repo.owner; + const repo = context.repo.repo; + const eventName = context.eventName; + const runId = context.runId; + + // Get workflow file name from GITHUB_WORKFLOW_REF (format: "owner/repo/.github/workflows/file.yml@ref") + // or fall back to GITHUB_WORKFLOW (workflow name) + const workflowRef = process.env.GITHUB_WORKFLOW_REF || ""; + let workflowId = context.workflow; // Default to workflow name + + if (workflowRef) { + // Extract workflow file from the ref (e.g., ".github/workflows/test.lock.yml@refs/heads/main") + const match = workflowRef.match(/\.github\/workflows\/([^@]+)/); + if (match && match[1]) { + workflowId = match[1]; + core.info(` Using workflow file: ${workflowId} (from GITHUB_WORKFLOW_REF)`); + } else { + core.info(` Using workflow name: ${workflowId} (fallback - could not parse GITHUB_WORKFLOW_REF)`); + } + } else { + core.info(` Using workflow name: ${workflowId} (GITHUB_WORKFLOW_REF not available)`); + } + + // Get configuration from environment variables + const maxRuns = parseInt(process.env.GH_AW_RATE_LIMIT_MAX || "5", 10); + const windowMinutes = parseInt(process.env.GH_AW_RATE_LIMIT_WINDOW || "60", 10); + const eventsList = process.env.GH_AW_RATE_LIMIT_EVENTS || ""; + + core.info(`🔍 Checking rate limit for user '${actor}' on workflow '${workflowId}'`); + core.info(` Configuration: max=${maxRuns} runs per ${windowMinutes} minutes`); + core.info(` Current event: ${eventName}`); + + // Parse events to apply rate limiting to + const limitedEvents = eventsList ? eventsList.split(",").map(e => e.trim()) : []; + + // If specific events are configured, check if current event should be limited + if (limitedEvents.length > 0) { + if (!limitedEvents.includes(eventName)) { + core.info(`✅ Event '${eventName}' is not subject to rate limiting`); + core.info(` Rate limiting applies only to: ${limitedEvents.join(", ")}`); + core.setOutput("rate_limit_ok", "true"); + return; + } + core.info(` Event '${eventName}' is subject to rate limiting`); + } else { + // When no specific events are configured, apply rate limiting only to + // known programmatic triggers. Allow all other events. + const programmaticEvents = ["workflow_dispatch", "repository_dispatch", "issue_comment", "pull_request_review", "pull_request_review_comment", "discussion_comment"]; + + if (!programmaticEvents.includes(eventName)) { + core.info(`✅ Event '${eventName}' is not a programmatic trigger; skipping rate limiting`); + core.info(` Rate limiting applies to: ${programmaticEvents.join(", ")}`); + core.setOutput("rate_limit_ok", "true"); + return; + } + + core.info(` Rate limiting applies to programmatic events: ${programmaticEvents.join(", ")}`); + } + + // Calculate time threshold + const windowMs = windowMinutes * 60 * 1000; + const thresholdTime = new Date(Date.now() - windowMs); + const thresholdISO = thresholdTime.toISOString(); + + core.info(` Time window: runs created after ${thresholdISO}`); + + try { + // Collect recent workflow runs by event type + // This allows us to aggregate counts and short-circuit when max is exceeded + let totalRecentRuns = 0; + const runsPerEvent = {}; + + core.info(`📊 Querying workflow runs for '${workflowId}'...`); + + // Query workflow runs (paginated if needed) + let page = 1; + let hasMore = true; + const perPage = 100; + + while (hasMore && totalRecentRuns < maxRuns) { + core.info(` Fetching page ${page} (up to ${perPage} runs per page)...`); + + const response = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: workflowId, + per_page: perPage, + page, + }); + + const runs = response.data.workflow_runs; + core.info(` Retrieved ${runs.length} runs from page ${page}`); + + if (runs.length === 0) { + hasMore = false; + break; + } + + // Filter runs by actor and time window + for (const run of runs) { + // Stop processing if we've already exceeded the limit + if (totalRecentRuns >= maxRuns) { + core.info(` Short-circuit: Already found ${totalRecentRuns} runs (>= max ${maxRuns})`); + hasMore = false; + break; + } + + // Skip if run is older than the time window + const runCreatedAt = new Date(run.created_at); + if (runCreatedAt < thresholdTime) { + core.info(` Skipping run ${run.id} - created before threshold (${run.created_at})`); + continue; + } + + // Check if run is by the same actor + if (run.actor?.login !== actor) { + continue; + } + + // Skip the current run (we're checking if we should allow THIS run) + if (run.id === runId) { + continue; + } + + // Skip cancelled workflow runs (they don't count toward the rate limit) + // GitHub uses conclusion: 'cancelled' with status: 'completed' for cancelled runs + if (run.conclusion === "cancelled") { + core.info(` Skipping run ${run.id} - cancelled (conclusion: ${run.conclusion})`); + continue; + } + + // Skip runs that completed in less than 15 seconds (treat as cancelled/failed fast) + if (run.created_at && run.updated_at) { + const runStart = new Date(run.created_at); + const runEnd = new Date(run.updated_at); + const durationSeconds = (runEnd.getTime() - runStart.getTime()) / 1000; + + if (durationSeconds < 15) { + core.info(` Skipping run ${run.id} - ran for less than 15s (${durationSeconds.toFixed(1)}s)`); + continue; + } + } + + // If specific events are configured, only count matching events + const runEvent = run.event; + if (limitedEvents.length > 0 && !limitedEvents.includes(runEvent)) { + continue; + } + + // Count this run + totalRecentRuns++; + runsPerEvent[runEvent] = (runsPerEvent[runEvent] || 0) + 1; + + core.info(` ✓ Run #${run.run_number} (${run.id}) by ${run.actor?.login} - ` + `event: ${runEvent}, created: ${run.created_at}, status: ${run.status}`); + } + + // Check if we should fetch more pages + if (runs.length < perPage || totalRecentRuns >= maxRuns) { + hasMore = false; + } else { + page++; + } + } + + // Log summary by event type + core.info(`📈 Rate limit summary for user '${actor}':`); + core.info(` Total recent runs in last ${windowMinutes} minutes: ${totalRecentRuns}`); + core.info(` Maximum allowed: ${maxRuns}`); + + if (Object.keys(runsPerEvent).length > 0) { + core.info(` Breakdown by event type:`); + for (const [event, count] of Object.entries(runsPerEvent)) { + core.info(` - ${event}: ${count} runs`); + } + } + + // Check if rate limit is exceeded + if (totalRecentRuns >= maxRuns) { + core.warning(`⚠️ Rate limit exceeded for user '${actor}' on workflow '${workflowId}'`); + core.warning(` User has triggered ${totalRecentRuns} runs in the last ${windowMinutes} minutes (max: ${maxRuns})`); + core.warning(` Cancelling current workflow run...`); + + // Cancel the current workflow run + try { + await github.rest.actions.cancelWorkflowRun({ + owner, + repo, + run_id: runId, + }); + core.warning(`✅ Workflow run ${runId} cancelled successfully`); + } catch (cancelError) { + const errorMsg = cancelError instanceof Error ? cancelError.message : String(cancelError); + core.error(`❌ Failed to cancel workflow run: ${errorMsg}`); + // Continue anyway - the rate limit output will still be set to false + } + + core.setOutput("rate_limit_ok", "false"); + return; + } + + // Rate limit not exceeded + core.info(`✅ Rate limit check passed`); + core.info(` User '${actor}' has ${totalRecentRuns} runs in the last ${windowMinutes} minutes`); + core.info(` Remaining quota: ${maxRuns - totalRecentRuns} runs`); + core.setOutput("rate_limit_ok", "true"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : ""; + core.error(`❌ Rate limit check failed: ${errorMsg}`); + if (errorStack) { + core.error(` Stack trace: ${errorStack}`); + } + + // On error, allow the workflow to proceed (fail-open) + // This prevents rate limiting from blocking workflows due to API issues + core.warning(`⚠️ Allowing workflow to proceed due to rate limit check error`); + core.setOutput("rate_limit_ok", "true"); + } +} + +module.exports = { main }; diff --git a/actions/setup/js/check_rate_limit.test.cjs b/actions/setup/js/check_rate_limit.test.cjs new file mode 100644 index 0000000000..765b763ba7 --- /dev/null +++ b/actions/setup/js/check_rate_limit.test.cjs @@ -0,0 +1,561 @@ +// @ts-check +import { describe, it, expect, beforeEach, vi } from "vitest"; + +describe("check_rate_limit", () => { + let mockCore; + let mockGithub; + let mockContext; + let checkRateLimit; + + beforeEach(async () => { + // Mock @actions/core + mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setOutput: vi.fn(), + setFailed: vi.fn(), + }; + + // Mock @actions/github + mockGithub = { + rest: { + actions: { + listWorkflowRuns: vi.fn(), + cancelWorkflowRun: vi.fn(), + }, + }, + }; + + // Mock context + mockContext = { + actor: "test-user", + repo: { + owner: "test-owner", + repo: "test-repo", + }, + workflow: "test-workflow", + eventName: "workflow_dispatch", + runId: 123456, + }; + + // Setup global mocks + global.core = mockCore; + global.github = mockGithub; + global.context = mockContext; + + // Reset environment variables + delete process.env.GH_AW_RATE_LIMIT_MAX; + delete process.env.GH_AW_RATE_LIMIT_WINDOW; + delete process.env.GH_AW_RATE_LIMIT_EVENTS; + + // Reload the module to get fresh instance + vi.resetModules(); + checkRateLimit = await import("./check_rate_limit.cjs"); + }); + + it("should pass when no recent runs by actor", async () => { + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Rate limit check passed")); + }); + + it("should pass when recent runs are below limit", async () => { + const oneHourAgo = new Date(Date.now() - 30 * 60 * 1000); // 30 minutes ago + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: oneHourAgo.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 222222, + run_number: 2, + created_at: oneHourAgo.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 2")); + }); + + it("should fail when rate limit is exceeded", async () => { + process.env.GH_AW_RATE_LIMIT_MAX = "3"; + const recentTime = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 333333, + run_number: 3, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + mockGithub.rest.actions.cancelWorkflowRun.mockResolvedValue({}); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "false"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Rate limit exceeded")); + expect(mockGithub.rest.actions.cancelWorkflowRun).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + run_id: 123456, + }); + }); + + it("should only count runs by the same actor", async () => { + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + actor: { login: "other-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 333333, + run_number: 3, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 2")); + }); + + it("should exclude runs older than the time window", async () => { + const twoHoursAgo = new Date(Date.now() - 120 * 60 * 1000); + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: twoHoursAgo.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 1")); + }); + + it("should exclude the current run from the count", async () => { + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 123456, // Current run ID + run_number: 1, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "in_progress", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 1")); + }); + + it("should exclude cancelled runs from the count", async () => { + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + conclusion: "cancelled", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + conclusion: "success", + }, + { + id: 333333, + run_number: 3, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + conclusion: "cancelled", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 1")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping run 111111 - cancelled")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping run 333333 - cancelled")); + }); + + it("should exclude runs that lasted less than 15 seconds", async () => { + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + const tenSecondsLater = new Date(recentTime.getTime() + 10 * 1000); + const twentySecondsLater = new Date(recentTime.getTime() + 20 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + updated_at: tenSecondsLater.toISOString(), // 10 seconds duration + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + updated_at: twentySecondsLater.toISOString(), // 20 seconds duration + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 1")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping run 111111 - ran for less than 15s")); + }); + + it("should only count specified event types when events filter is set", async () => { + process.env.GH_AW_RATE_LIMIT_EVENTS = "workflow_dispatch,issue_comment"; + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "push", + status: "completed", + }, + { + id: 333333, + run_number: 3, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "issue_comment", + status: "completed", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Total recent runs in last 60 minutes: 2")); + }); + + it("should skip rate limiting if current event is not in the events filter", async () => { + process.env.GH_AW_RATE_LIMIT_EVENTS = "issue_comment,pull_request"; + mockContext.eventName = "workflow_dispatch"; + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Event 'workflow_dispatch' is not subject to rate limiting")); + expect(mockGithub.rest.actions.listWorkflowRuns).not.toHaveBeenCalled(); + }); + + it("should use custom max and window values", async () => { + process.env.GH_AW_RATE_LIMIT_MAX = "10"; + process.env.GH_AW_RATE_LIMIT_WINDOW = "30"; + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("max=10 runs per 30 minutes")); + }); + + it("should short-circuit when max is exceeded during pagination", async () => { + process.env.GH_AW_RATE_LIMIT_MAX = "2"; + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + // First page returns 2 runs (exceeds limit) + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValueOnce({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + mockGithub.rest.actions.cancelWorkflowRun.mockResolvedValue({}); + + await checkRateLimit.main(); + + // Should only call once, not fetch second page + expect(mockGithub.rest.actions.listWorkflowRuns).toHaveBeenCalledTimes(1); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "false"); + }); + + it("should fail-open on API errors", async () => { + mockGithub.rest.actions.listWorkflowRuns.mockRejectedValue(new Error("API error")); + + await checkRateLimit.main(); + + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Rate limit check failed")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Allowing workflow to proceed")); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + }); + + it("should continue even if cancellation fails", async () => { + process.env.GH_AW_RATE_LIMIT_MAX = "1"; + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + mockGithub.rest.actions.cancelWorkflowRun.mockRejectedValue(new Error("Cancellation failed")); + + await checkRateLimit.main(); + + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to cancel workflow run")); + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "false"); + }); + + it("should provide breakdown by event type", async () => { + const recentTime = new Date(Date.now() - 10 * 60 * 1000); + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [ + { + id: 111111, + run_number: 1, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + { + id: 222222, + run_number: 2, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "issue_comment", + status: "completed", + }, + { + id: 333333, + run_number: 3, + created_at: recentTime.toISOString(), + actor: { login: "test-user" }, + event: "workflow_dispatch", + status: "completed", + }, + ], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Breakdown by event type:")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("workflow_dispatch: 2 runs")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("issue_comment: 1 runs")); + }); + + it("should skip rate limiting for non-programmatic events when no events filter is set", async () => { + mockContext.eventName = "push"; + + await checkRateLimit.main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Event 'push' is not a programmatic trigger")); + expect(mockGithub.rest.actions.listWorkflowRuns).not.toHaveBeenCalled(); + }); + + it("should use workflow file from GITHUB_WORKFLOW_REF when available", async () => { + process.env.GITHUB_WORKFLOW_REF = "owner/repo/.github/workflows/test.lock.yml@refs/heads/main"; + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using workflow file: test.lock.yml")); + expect(mockGithub.rest.actions.listWorkflowRuns).toHaveBeenCalledWith( + expect.objectContaining({ + workflow_id: "test.lock.yml", + }) + ); + }); + + it("should fall back to workflow name when GITHUB_WORKFLOW_REF is not parseable", async () => { + process.env.GITHUB_WORKFLOW_REF = "invalid-format"; + + mockGithub.rest.actions.listWorkflowRuns.mockResolvedValue({ + data: { + workflow_runs: [], + }, + }); + + await checkRateLimit.main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using workflow name: test-workflow (fallback")); + }); +}); diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md new file mode 100644 index 0000000000..f386312084 --- /dev/null +++ b/docs/RATE_LIMITING.md @@ -0,0 +1,312 @@ +# Rate Limiting for Agentic Workflows + +## Overview + +The rate limiting feature prevents users from triggering workflows too frequently, helping to: +- Prevent abuse and resource exhaustion +- Control costs from programmatic workflow triggers +- Protect against accidental infinite loops +- Ensure fair resource allocation across users + +## Configuration + +Rate limiting is configured in the workflow frontmatter using the `rate-limit` field: + +```yaml +--- +name: My Workflow +engine: copilot +on: + workflow_dispatch: + issue_comment: + types: [created] +rate-limit: + max: 5 # Required: 1-10 runs + window: 60 # Optional: minutes (default 60, max 180) + # events field is optional - automatically inferred from 'on:' triggers +--- +``` + +## Parameters + +### `max` (integer, **required**) +- Maximum number of workflow runs allowed per user within the time window +- Range: 1-10 +- Example: `max: 5` allows 5 runs per window + +### `window` (integer, optional) +- Time window in minutes for rate limiting +- Default: 60 (1 hour) +- Range: 1-180 (up to 3 hours) +- Example: `window: 30` creates a 30-minute window + +### `events` (array, optional) +- Specific event types to apply rate limiting to +- **If not specified, automatically inferred from the workflow's `on:` triggers** +- Only programmatic trigger types are included in the inference +- Can be explicitly set to override the inference +- Supported events: + - `workflow_dispatch` + - `issue_comment` + - `pull_request_review` + - `pull_request_review_comment` + - `issues` + - `pull_request` + - `discussion_comment` + - `discussion` + +## How It Works + +1. **Pre-Activation Check**: Rate limiting is enforced in the pre-activation job, before the main workflow runs +2. **Per-User Per-Workflow**: Limits are applied individually for each user and workflow +3. **Recent Runs Query**: The system queries recent workflow runs from the GitHub API +4. **Filtering**: Runs are filtered by: + - Actor (user who triggered the workflow) + - Time window (only runs within the configured window) + - Event type (if `events` is configured) + - Excludes the current run from the count + - Excludes cancelled runs (cancelled runs don't count toward the limit) + - Excludes runs that completed in less than 15 seconds (treated as failed fast/cancelled) +5. **Progressive Aggregation**: Uses pagination with short-circuit logic for efficiency +6. **Automatic Cancellation**: If the limit is exceeded, the current run is automatically cancelled + +## Examples + +### Automatic Event Inference (Recommended) +```yaml +on: + issues: + types: [opened] + issue_comment: + types: [created] +rate-limit: + max: 5 + window: 60 + # Events automatically inferred: [issues, issue_comment] +``` +Events are automatically inferred from the workflow's triggers. Simplest configuration. + +### Basic Rate Limiting (Default Window) +```yaml +rate-limit: + max: 5 + window: 60 +``` +Allows 5 runs per hour. Events inferred from `on:` section. + +### Explicit Event Filtering +```yaml +rate-limit: + max: 3 + window: 30 + events: [workflow_dispatch, issue_comment] +``` +Explicitly specify events to override inference. Allows only 3 runs per 30 minutes for the specified events. + +### Generous Rate Limiting +```yaml +rate-limit: + max: 10 + window: 120 +``` +Allows 10 runs per 2 hours. Events inferred from triggers. + +## Behavior Details + +### When Rate Limit is Exceeded +- The workflow run is automatically cancelled +- A warning message is logged with details: + - Current run count + - Maximum allowed + - Time window +- The activation output is set to false, preventing the main job from running + +### Logging +The rate limit check provides extensive logging: +``` +🔍 Checking rate limit for user 'username' on workflow 'workflow-name' + Configuration: max=5 runs per 60 minutes + Current event: workflow_dispatch + Time window: runs created after 2026-02-11T11:24:33.098Z +📊 Querying workflow runs for 'workflow-name'... + Fetching page 1 (up to 100 runs per page)... + Retrieved 10 runs from page 1 + Skipping run 123457 - cancelled (status: cancelled) + ✓ Run #5 (123456) by username - event: workflow_dispatch, created: 2026-02-11T11:15:00.000Z, status: completed +📈 Rate limit summary for user 'username': + Total recent runs in last 60 minutes: 3 + Maximum allowed: 5 + Breakdown by event type: + - workflow_dispatch: 2 runs + - issue_comment: 1 runs +✅ Rate limit check passed + User 'username' has 3 runs in the last 60 minutes + Remaining quota: 2 runs +``` + +### Error Handling +- **Fail-Open**: If the rate limit check fails due to API errors, the workflow is allowed to proceed +- This ensures that temporary API issues don't block legitimate workflow runs +- Errors are logged with details for troubleshooting + +### Performance Optimization +- **Short-Circuit Logic**: Stops querying additional pages once the limit is reached +- **Progressive Filtering**: Filters by actor and time window progressively +- **Pagination**: Efficiently handles workflows with many runs + +## Integration with Pre-Activation Job + +The rate limit check is automatically added to the pre-activation job when configured: + +```yaml +jobs: + pre-activation: + runs-on: ubuntu-latest + outputs: + activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} + steps: + - name: Check team membership + # ... membership check ... + + - name: Check user rate limit + id: check_rate_limit + uses: actions/github-script@v8 + env: + GH_AW_RATE_LIMIT_MAX: "5" + GH_AW_RATE_LIMIT_WINDOW: "60" + GH_AW_RATE_LIMIT_EVENTS: workflow_dispatch,issue_comment + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { main } = require('/opt/gh-aw/actions/check_rate_limit.cjs'); + await main(); +``` + +The activation output combines all pre-activation checks using AND logic, so the workflow only proceeds if all checks pass. + +## Use Cases + +### Preventing Abuse +```yaml +rate-limit: + max: 3 + window: 60 + events: [workflow_dispatch] +``` +Limits manual workflow triggers to prevent spam or abuse. + +### Cost Control +```yaml +rate-limit: + max: 10 + window: 120 +``` +Controls costs by limiting how often expensive workflows can be triggered. + +### Fair Resource Allocation +```yaml +rate-limit: + max: 5 + window: 30 +``` +Ensures fair access to shared resources across multiple users. + +### Development vs Production +Development workflows might have stricter limits: +```yaml +# Development +rate-limit: + max: 3 + window: 30 + +# Production +rate-limit: + max: 20 + window: 60 +``` + +## Testing + +A test workflow is provided at `.github/workflows/test-rate-limit.md`: + +```yaml +--- +name: Test Rate Limiting +engine: copilot +on: + workflow_dispatch: + issue_comment: + types: [created] +rate-limit: + max: 3 + window: 30 + events: [workflow_dispatch, issue_comment] +--- + +Test workflow to demonstrate rate limiting functionality. +This workflow limits each user to 3 runs within a 30-minute window. +``` + +To test: +1. Trigger the workflow manually 4 times in quick succession +2. The 4th run should be automatically cancelled with a rate limit warning +3. Wait 30 minutes for the window to reset +4. Trigger again to confirm the limit resets + +## Troubleshooting + +### Rate Limit Not Working +- Check that `rate-limit` is in the workflow frontmatter +- Verify the schema is valid (run `gh aw compile`) +- Check pre-activation job logs for rate limit check output + +### Unexpected Cancellations +- Review the rate limit configuration (`max` and `window`) +- Check if other users are triggering the same workflow +- Verify event filters are configured correctly + +### API Errors +- Rate limit checks fail-open on API errors +- Check GitHub API status if issues persist +- Review workflow run logs for detailed error messages + +## Schema Definition + +The rate-limit field is validated against this JSON schema: + +```json +{ + "type": "object", + "required": ["max"], + "properties": { + "max": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "window": { + "type": "integer", + "minimum": 1, + "maximum": 180, + "default": 60 + }, + "events": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "workflow_dispatch", + "issue_comment", + "pull_request_review", + "pull_request_review_comment", + "issues", + "pull_request", + "discussion_comment", + "discussion" + ] + }, + "minItems": 1 + } + } +} +``` diff --git a/docs/src/content/docs/agent-factory-status.mdx b/docs/src/content/docs/agent-factory-status.mdx index b6093fe58b..da81e57932 100644 --- a/docs/src/content/docs/agent-factory-status.mdx +++ b/docs/src/content/docs/agent-factory-status.mdx @@ -143,6 +143,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn, | [Test Create PR Error Handling](https://github.com/github/gh-aw/blob/main/.github/workflows/test-create-pr-error-handling.md) | claude | [![Test Create PR Error Handling](https://github.com/github/gh-aw/actions/workflows/test-create-pr-error-handling.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-create-pr-error-handling.lock.yml) | - | - | | [Test Dispatcher Workflow](https://github.com/github/gh-aw/blob/main/.github/workflows/test-dispatcher.md) | copilot | [![Test Dispatcher Workflow](https://github.com/github/gh-aw/actions/workflows/test-dispatcher.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-dispatcher.lock.yml) | - | - | | [Test Project URL Explicit Requirement](https://github.com/github/gh-aw/blob/main/.github/workflows/test-project-url-default.md) | copilot | [![Test Project URL Explicit Requirement](https://github.com/github/gh-aw/actions/workflows/test-project-url-default.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-project-url-default.lock.yml) | - | - | +| [Test Rate Limiting](https://github.com/github/gh-aw/blob/main/.github/workflows/test-rate-limit.md) | copilot | [![Test Rate Limiting](https://github.com/github/gh-aw/actions/workflows/test-rate-limit.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-rate-limit.lock.yml) | - | - | | [Test Workflow](https://github.com/github/gh-aw/blob/main/.github/workflows/test-workflow.md) | copilot | [![Test Workflow](https://github.com/github/gh-aw/actions/workflows/test-workflow.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/test-workflow.lock.yml) | - | - | | [The Daily Repository Chronicle](https://github.com/github/gh-aw/blob/main/.github/workflows/daily-repo-chronicle.md) | copilot | [![The Daily Repository Chronicle](https://github.com/github/gh-aw/actions/workflows/daily-repo-chronicle.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/daily-repo-chronicle.lock.yml) | `0 16 * * 1-5` | - | | [The Great Escapi](https://github.com/github/gh-aw/blob/main/.github/workflows/firewall-escape.md) | copilot | [![The Great Escapi](https://github.com/github/gh-aw/actions/workflows/firewall-escape.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/firewall-escape.lock.yml) | - | - | diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index c577b29ffc..1bce7bc39b 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -3507,6 +3507,27 @@ bots: [] # Array of Bot identifier/name (e.g., 'dependabot[bot]', 'renovate[bot]', # 'github-actions[bot]') +# Rate limiting configuration to restrict how frequently users can trigger the +# workflow. Helps prevent abuse and resource exhaustion from programmatically +# triggered events. +# (optional) +rate-limit: + # Maximum number of workflow runs allowed per user within the time window. + # Defaults to 5. + # (optional) + max: 1 + + # Time window in minutes for rate limiting. Defaults to 60 (1 hour). + # (optional) + window: 1 + + # Optional list of event types to apply rate limiting to. If not specified, rate + # limiting applies to all programmatically triggered events (e.g., + # workflow_dispatch, issue_comment, pull_request_review). + # (optional) + events: [] + # Array of strings + # Enable strict mode validation for enhanced security and compliance. Strict mode # enforces: (1) Write Permissions - refuses contents:write, issues:write, # pull-requests:write; requires safe-outputs instead, (2) Network Configuration - diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index f3309c7322..78dbe69ef7 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -607,6 +607,7 @@ const CheckStopTimeStepID StepID = "check_stop_time" const CheckSkipIfMatchStepID StepID = "check_skip_if_match" const CheckSkipIfNoMatchStepID StepID = "check_skip_if_no_match" const CheckCommandPositionStepID StepID = "check_command_position" +const CheckRateLimitStepID StepID = "check_rate_limit" // Output names for pre-activation job steps const IsTeamMemberOutput = "is_team_member" @@ -615,8 +616,13 @@ const SkipCheckOkOutput = "skip_check_ok" const SkipNoMatchCheckOkOutput = "skip_no_match_check_ok" const CommandPositionOkOutput = "command_position_ok" const MatchedCommandOutput = "matched_command" +const RateLimitOkOutput = "rate_limit_ok" const ActivatedOutput = "activated" +// Rate limit defaults +const DefaultRateLimitMax = 5 // Default maximum runs per time window +const DefaultRateLimitWindow = 60 // Default time window in minutes (1 hour) + // Agentic engine name constants using EngineName type for type safety const ( // CopilotEngine is the GitHub Copilot engine identifier diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 822e2aa310..cb39acf026 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6087,6 +6087,47 @@ "description": "Bot identifier/name (e.g., 'dependabot[bot]', 'renovate[bot]', 'github-actions[bot]')" } }, + "rate-limit": { + "type": "object", + "description": "Rate limiting configuration to restrict how frequently users can trigger the workflow. Helps prevent abuse and resource exhaustion from programmatically triggered events.", + "required": ["max"], + "properties": { + "max": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "Maximum number of workflow runs allowed per user within the time window. Required field." + }, + "window": { + "type": "integer", + "minimum": 1, + "maximum": 180, + "default": 60, + "description": "Time window in minutes for rate limiting. Defaults to 60 (1 hour). Maximum: 180 (3 hours)." + }, + "events": { + "type": "array", + "description": "Optional list of event types to apply rate limiting to. If not specified, rate limiting applies to all programmatically triggered events (e.g., workflow_dispatch, issue_comment, pull_request_review).", + "items": { + "type": "string", + "enum": ["workflow_dispatch", "issue_comment", "pull_request_review", "pull_request_review_comment", "issues", "pull_request", "discussion_comment", "discussion"] + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "examples": [ + { + "max": 5, + "window": 60 + }, + { + "max": 10, + "window": 30, + "events": ["workflow_dispatch", "issue_comment"] + } + ] + }, "strict": { "type": "boolean", "default": true, diff --git a/pkg/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go index fa68aaa9e4..813d45238c 100644 --- a/pkg/workflow/compiler_activation_jobs.go +++ b/pkg/workflow/compiler_activation_jobs.go @@ -56,6 +56,14 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec perms.Set(PermissionDiscussions, PermissionWrite) } + // Add actions: read permission if rate limiting is configured (needed to query workflow runs) + if data.RateLimit != nil { + if perms == nil { + perms = NewPermissions() + } + perms.Set(PermissionActions, PermissionRead) + } + // Set permissions if any were configured if perms != nil { permissions = perms.RenderToYAML() @@ -89,6 +97,11 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = c.generateMembershipCheck(data, steps) } + // Add rate limit check if configured + if data.RateLimit != nil { + steps = c.generateRateLimitCheck(data, steps) + } + // Add stop-time check if configured if data.StopTime != "" { // Extract workflow name for the stop-time check @@ -207,6 +220,16 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec conditions = append(conditions, skipNoMatchCheckOk) } + if data.RateLimit != nil { + // Add rate limit check condition + rateLimitCheck := BuildComparison( + BuildPropertyAccess(fmt.Sprintf("steps.%s.outputs.%s", constants.CheckRateLimitStepID, constants.RateLimitOkOutput)), + "==", + BuildStringLiteral("true"), + ) + conditions = append(conditions, rateLimitCheck) + } + if len(data.Command) > 0 { // Add command position check condition commandPositionCheck := BuildComparison( diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 57d9598ed8..1691b26786 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -419,6 +419,7 @@ func (c *Compiler) extractAdditionalConfigurations( workflowData.Roles = c.extractRoles(frontmatter) workflowData.Bots = c.extractBots(frontmatter) + workflowData.RateLimit = c.extractRateLimitConfig(frontmatter) // Use the already extracted output configuration workflowData.SafeOutputs = safeOutputs diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 3bed652e2e..2a5d51091f 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -431,6 +431,7 @@ type WorkflowData struct { SafeInputs *SafeInputsConfig // safe-inputs configuration for custom MCP tools Roles []string // permission levels required to trigger workflow Bots []string // allow list of bot identifiers that can trigger workflow + RateLimit *RateLimitConfig // rate limiting configuration for workflow triggers CacheMemoryConfig *CacheMemoryConfig // parsed cache-memory configuration RepoMemoryConfig *RepoMemoryConfig // parsed repo-memory configuration Runtimes map[string]any // runtime version overrides from frontmatter diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 0ececb8ee7..f9bced5aa3 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -76,6 +76,14 @@ type PluginsConfig struct { GitHubToken string `json:"github-token,omitempty"` // Custom GitHub token for plugin installation } +// RateLimitConfig represents rate limiting configuration for workflow triggers +// Limits how many times a user can trigger a workflow within a time window +type RateLimitConfig struct { + Max int `json:"max,omitempty"` // Maximum number of runs allowed per time window (default: 5) + Window int `json:"window,omitempty"` // Time window in minutes (default: 60) + Events []string `json:"events,omitempty"` // Event types to apply rate limiting to (e.g., ["workflow_dispatch", "issue_comment"]) +} + // FrontmatterConfig represents the structured configuration from workflow frontmatter // This provides compile-time type safety and clearer error messages compared to map[string]any type FrontmatterConfig struct { @@ -143,8 +151,9 @@ type FrontmatterConfig struct { GithubToken string `json:"github-token,omitempty"` // Command/bot configuration - Roles []string `json:"roles,omitempty"` - Bots []string `json:"bots,omitempty"` + Roles []string `json:"roles,omitempty"` + Bots []string `json:"bots,omitempty"` + RateLimit *RateLimitConfig `json:"rate-limit,omitempty"` } // unmarshalFromMap converts a value from a map[string]any to a destination variable diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index 9d89a5ef7a..2446f1e9b2 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -37,6 +37,42 @@ func (c *Compiler) generateMembershipCheck(data *WorkflowData, steps []string) [ return steps } +// generateRateLimitCheck generates steps for rate limiting check +func (c *Compiler) generateRateLimitCheck(data *WorkflowData, steps []string) []string { + steps = append(steps, " - name: Check user rate limit\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckRateLimitStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + + // Add environment variables for rate limit check + steps = append(steps, " env:\n") + + // Set max (default: 5) + max := constants.DefaultRateLimitMax + if data.RateLimit.Max > 0 { + max = data.RateLimit.Max + } + steps = append(steps, fmt.Sprintf(" GH_AW_RATE_LIMIT_MAX: \"%d\"\n", max)) + + // Set window (default: 60 minutes) + window := constants.DefaultRateLimitWindow + if data.RateLimit.Window > 0 { + window = data.RateLimit.Window + } + steps = append(steps, fmt.Sprintf(" GH_AW_RATE_LIMIT_WINDOW: \"%d\"\n", window)) + + // Set events to check (if specified) + if len(data.RateLimit.Events) > 0 { + steps = append(steps, fmt.Sprintf(" GH_AW_RATE_LIMIT_EVENTS: %q\n", strings.Join(data.RateLimit.Events, ","))) + } + + steps = append(steps, " with:\n") + steps = append(steps, " github-token: ${{ secrets.GITHUB_TOKEN }}\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("check_rate_limit.cjs")) + + return steps +} + // extractRoles extracts the 'roles' field from frontmatter to determine permission requirements func (c *Compiler) extractRoles(frontmatter map[string]any) []string { if rolesValue, exists := frontmatter["roles"]; exists { @@ -101,6 +137,115 @@ func (c *Compiler) extractBots(frontmatter map[string]any) []string { return []string{} } +// extractRateLimitConfig extracts the 'rate-limit' field from frontmatter +func (c *Compiler) extractRateLimitConfig(frontmatter map[string]any) *RateLimitConfig { + if rateLimitValue, exists := frontmatter["rate-limit"]; exists && rateLimitValue != nil { + switch v := rateLimitValue.(type) { + case map[string]any: + config := &RateLimitConfig{} + + // Extract max (default: 5) + if maxValue, ok := v["max"]; ok { + switch max := maxValue.(type) { + case int: + config.Max = max + case int64: + config.Max = int(max) + case uint64: + config.Max = int(max) + case float64: + config.Max = int(max) + } + } + + // Extract window (default: 60 minutes) + if windowValue, ok := v["window"]; ok { + switch window := windowValue.(type) { + case int: + config.Window = window + case int64: + config.Window = int(window) + case uint64: + config.Window = int(window) + case float64: + config.Window = int(window) + } + } + + // Extract events + if eventsValue, ok := v["events"]; ok { + switch events := eventsValue.(type) { + case []any: + for _, item := range events { + if str, ok := item.(string); ok { + config.Events = append(config.Events, str) + } + } + case []string: + config.Events = events + case string: + config.Events = []string{events} + } + } else { + // If events not specified, infer from the 'on:' section of frontmatter + config.Events = c.inferEventsFromTriggers(frontmatter) + if len(config.Events) > 0 { + roleLog.Printf("Inferred events from workflow triggers: %v", config.Events) + } + } + + roleLog.Printf("Extracted rate-limit config: max=%d, window=%d, events=%v", config.Max, config.Window, config.Events) + return config + } + } + roleLog.Print("No rate-limit configuration specified") + return nil +} + +// inferEventsFromTriggers infers rate-limit events from the workflow's 'on:' triggers +func (c *Compiler) inferEventsFromTriggers(frontmatter map[string]any) []string { + onValue, exists := frontmatter["on"] + if !exists || onValue == nil { + return nil + } + + var events []string + programmaticTriggers := map[string]string{ + "workflow_dispatch": "workflow_dispatch", + "repository_dispatch": "repository_dispatch", + "issues": "issues", + "issue_comment": "issue_comment", + "pull_request": "pull_request", + "pull_request_review": "pull_request_review", + "pull_request_review_comment": "pull_request_review_comment", + "discussion": "discussion", + "discussion_comment": "discussion_comment", + } + + switch on := onValue.(type) { + case map[string]any: + for trigger := range on { + if eventName, ok := programmaticTriggers[trigger]; ok { + events = append(events, eventName) + } + } + case []any: + for _, item := range on { + if triggerStr, ok := item.(string); ok { + if eventName, ok := programmaticTriggers[triggerStr]; ok { + events = append(events, eventName) + } + } + } + case string: + if eventName, ok := programmaticTriggers[on]; ok { + events = []string{eventName} + } + } + + return events +} + // needsRoleCheck determines if the workflow needs permission checks with full context func (c *Compiler) needsRoleCheck(data *WorkflowData, frontmatter map[string]any) bool { // If user explicitly specified "roles: all", no permission checks needed diff --git a/pkg/workflow/role_checks_test.go b/pkg/workflow/role_checks_test.go index 3bae6859e1..2065f13dd6 100644 --- a/pkg/workflow/role_checks_test.go +++ b/pkg/workflow/role_checks_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" ) // TestRoleMembershipUsesGitHubToken tests that the role membership check @@ -146,3 +147,87 @@ Test that role membership check uses GITHUB_TOKEN with bots.` t.Errorf("Expected check_membership step to explicitly use 'github-token: ${{ secrets.GITHUB_TOKEN }}'") } } + +func TestInferEventsFromTriggers(t *testing.T) { + c := &Compiler{} + + tests := []struct { + name string + frontmatter map[string]any + expected []string + }{ + { + name: "infer from map with multiple triggers", + frontmatter: map[string]any{ + "on": map[string]any{ + "issues": map[string]any{"types": []any{"opened"}}, + "issue_comment": map[string]any{"types": []any{"created"}}, + "workflow_dispatch": nil, + }, + }, + expected: []string{"issues", "issue_comment", "workflow_dispatch"}, + }, + { + name: "infer only programmatic triggers", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{}, + "issues": map[string]any{}, + "schedule": "daily", + }, + }, + expected: []string{"issues"}, + }, + { + name: "no triggers", + frontmatter: map[string]any{ + "on": map[string]any{}, + }, + expected: nil, + }, + { + name: "missing on section", + frontmatter: map[string]any{}, + expected: nil, + }, + { + name: "all programmatic triggers", + frontmatter: map[string]any{ + "on": map[string]any{ + "workflow_dispatch": nil, + "repository_dispatch": nil, + "issues": map[string]any{}, + "issue_comment": map[string]any{}, + "pull_request": map[string]any{}, + "pull_request_review": map[string]any{}, + "pull_request_review_comment": map[string]any{}, + "discussion": map[string]any{}, + "discussion_comment": map[string]any{}, + }, + }, + expected: []string{ + "workflow_dispatch", + "repository_dispatch", + "issues", + "issue_comment", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "discussion", + "discussion_comment", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := c.inferEventsFromTriggers(tt.frontmatter) + // Use ElementsMatch since map iteration order is non-deterministic + if len(tt.expected) > 0 && len(result) > 0 { + assert.ElementsMatch(t, tt.expected, result, "Inferred events should match expected") + } else { + assert.Equal(t, tt.expected, result, "Inferred events should match expected") + } + }) + } +}