From bada850c0f67be2f5bd95e1827b3b155841ca4f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:20:25 +0000 Subject: [PATCH 01/14] Initial plan From a27b1225daab230e6dd0aad2afda7b96008a77d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:26:09 +0000 Subject: [PATCH 02/14] Initial commit: Add rate limit support infrastructure Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/actions-lock.json | 5 +++++ .github/workflows/release.lock.yml | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index c9ea38685d2..3688f8f2b9e 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -125,6 +125,11 @@ "version": "v2.0.3", "sha": "e95548e56dfa95d4e1a28d6f422fafe75c4c26fb" }, + "docker/build-push-action@v6": { + "repo": "docker/build-push-action", + "version": "v6", + "sha": "ee4ca427a2f43b6a16632044ca514c076267da23" + }, "docker/build-push-action@v6.18.0": { "repo": "docker/build-push-action", "version": "v6.18.0", diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 3a4828e86df..626adc4d786 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -1196,7 +1196,7 @@ jobs: - name: Setup Docker Buildx (pre-validation) uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build Docker image (validation only) - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 + uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6 with: build-args: | BINARY=dist/linux-amd64 @@ -1285,7 +1285,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image (amd64) id: build - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 + uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6 with: build-args: | BINARY=dist/linux-amd64 From 1dca0a3631d7a2348fcb044451a54a931fb3671a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:30:57 +0000 Subject: [PATCH 03/14] Add rate limiting infrastructure and JavaScript implementation - Add rate-limit configuration to FrontmatterConfig and WorkflowData - Add extractRateLimitConfig to parse rate-limit from frontmatter - Add generateRateLimitCheck to create rate limit check step - Implement check_rate_limit.cjs with comprehensive rate limiting logic - Add 13 test cases covering various scenarios - Add constants: CheckRateLimitStepID, RateLimitOkOutput - Add default rate limit constants (5 runs per 60 minutes) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_rate_limit.cjs | 184 ++++++++ actions/setup/js/check_rate_limit.test.cjs | 436 ++++++++++++++++++ pkg/constants/constants.go | 6 + pkg/workflow/compiler_activation_jobs.go | 15 + .../compiler_orchestrator_workflow.go | 1 + pkg/workflow/compiler_types.go | 1 + pkg/workflow/data/action_pins.json | 5 + pkg/workflow/frontmatter_types.go | 13 +- pkg/workflow/role_checks.go | 87 ++++ 9 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 actions/setup/js/check_rate_limit.cjs create mode 100644 actions/setup/js/check_rate_limit.test.cjs diff --git a/actions/setup/js/check_rate_limit.cjs b/actions/setup/js/check_rate_limit.cjs new file mode 100644 index 00000000000..22b5dc5c0bd --- /dev/null +++ b/actions/setup/js/check_rate_limit.cjs @@ -0,0 +1,184 @@ +// @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 workflowId = context.workflow; + const eventName = context.eventName; + const runId = context.runId; + + // 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 { + core.info(` Rate limiting applies to all programmatically triggered events`); + } + + // 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; + } + + // 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 00000000000..2cd09fb4048 --- /dev/null +++ b/actions/setup/js/check_rate_limit.test.cjs @@ -0,0 +1,436 @@ +// @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 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")); + }); +}); diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index f3309c73226..78dbe69ef7d 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/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go index fa68aaa9e43..a2fe66abf23 100644 --- a/pkg/workflow/compiler_activation_jobs.go +++ b/pkg/workflow/compiler_activation_jobs.go @@ -89,6 +89,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 +212,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 57d9598ed85..1691b267861 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 3bed652e2eb..2a5d51091f1 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/data/action_pins.json b/pkg/workflow/data/action_pins.json index c9ea38685d2..3688f8f2b9e 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -125,6 +125,11 @@ "version": "v2.0.3", "sha": "e95548e56dfa95d4e1a28d6f422fafe75c4c26fb" }, + "docker/build-push-action@v6": { + "repo": "docker/build-push-action", + "version": "v6", + "sha": "ee4ca427a2f43b6a16632044ca514c076267da23" + }, "docker/build-push-action@v6.18.0": { "repo": "docker/build-push-action", "version": "v6.18.0", diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 0ececb8ee7b..f9bced5aa32 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 9d89a5ef7ae..e8bd4300d2a 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: %s\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,57 @@ 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 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 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} + } + } + + 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 +} + // 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 From 0cdc6c0febff3273bf194e1cb33e267a178498b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:34:08 +0000 Subject: [PATCH 04/14] Fix rate-limit extraction to handle uint64 and add test workflow - Update extractRateLimitConfig to handle uint64, int64, int, and float64 - Add rate-limit schema definition to main_workflow_schema.json - Create test-rate-limit.md workflow demonstrating rate limiting - Rebuild binary to embed updated schema - Successfully compile test workflow with rate limits (max=3, window=30) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/test-rate-limit.lock.yml | 516 +++++++++++++++++++ .github/workflows/test-rate-limit.md | 16 + pkg/parser/schemas/main_workflow_schema.json | 41 ++ pkg/workflow/role_checks.go | 8 + 4 files changed, 581 insertions(+) create mode 100644 .github/workflows/test-rate-limit.lock.yml create mode 100644 .github/workflows/test-rate-limit.md diff --git a/.github/workflows/test-rate-limit.lock.yml b/.github/workflows/test-rate-limit.lock.yml new file mode 100644 index 00000000000..3f3e6a087d5 --- /dev/null +++ b/.github/workflows/test-rate-limit.lock.yml @@ -0,0 +1,516 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw. DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# For more information: https://github.com/github/gh-aw/blob/main/.github/aw/github-agentic-workflows.md +# +# +# frontmatter-hash: 19edebfa0f1d89f75903ffc8672771a2febe1ddd248592762851b1fcedc5f158 + +name: "Test Rate Limiting" +"on": + issue_comment: + types: + - created + workflow_dispatch: + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}" + +run-name: "Test Rate Limiting" + +jobs: + activation: + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "test-rate-limit.lock.yml" + with: + 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_workflow_timestamp_api.cjs'); + await main(); + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_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/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "copilot", + engine_name: "GitHub Copilot CLI", + model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", + version: "", + agent_version: "0.0.406", + workflow_name: "Test Rate Limiting", + experimental: false, + supports_tools_allowlist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.14.0", + awmg_version: "", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.406 + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.14.0 + - name: Determine automatic lockdown mode for GitHub MCP server + id: determine-automatic-lockdown + env: + TOKEN_CHECK: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + if: env.TOKEN_CHECK != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.14.0 ghcr.io/github/gh-aw-firewall/squid:0.14.0 ghcr.io/github/gh-aw-mcpg:v0.1.0 ghcr.io/github/github-mcp-server:v0.30.3 + - name: Start MCP gateway + id: start-mcp-gateway + env: + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.0' + + mkdir -p /home/runner/.copilot + cat << MCPCONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + MCPCONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || '' }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'PROMPT_EOF' > "$GH_AW_PROMPT" + + PROMPT_EOF + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + PROMPT_EOF + if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then + cat "/opt/gh-aw/prompts/pr_context_prompt.md" >> "$GH_AW_PROMPT" + fi + cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" + + PROMPT_EOF + cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import .github/workflows/test-rate-limit.md}} + PROMPT_EOF + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || '' }} + with: + script: | + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_IS_PR_COMMENT: process.env.GH_AW_IS_PR_COMMENT + } + }); + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + sudo -E awf --enable-chroot --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.14.0 --skip-pull \ + -- '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' \ + 2>&1 | tee /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload engine output files + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent_outputs + path: | + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + if-no-files-found: ignore + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + + pre_activation: + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + 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 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + 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_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: "3" + GH_AW_RATE_LIMIT_WINDOW: "30" + GH_AW_RATE_LIMIT_EVENTS: workflow_dispatch,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(); + diff --git a/.github/workflows/test-rate-limit.md b/.github/workflows/test-rate-limit.md new file mode 100644 index 00000000000..472e5831a98 --- /dev/null +++ b/.github/workflows/test-rate-limit.md @@ -0,0 +1,16 @@ +--- +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 for workflow_dispatch and issue_comment events. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 77da432af36..4e47e23662a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6075,6 +6075,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.", + "properties": { + "max": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 5, + "description": "Maximum number of workflow runs allowed per user within the time window. Defaults to 5." + }, + "window": { + "type": "integer", + "minimum": 1, + "maximum": 1440, + "default": 60, + "description": "Time window in minutes for rate limiting. Defaults to 60 (1 hour)." + }, + "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/role_checks.go b/pkg/workflow/role_checks.go index e8bd4300d2a..938e3659572 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -149,6 +149,10 @@ func (c *Compiler) extractRateLimitConfig(frontmatter map[string]any) *RateLimit 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) } @@ -159,6 +163,10 @@ func (c *Compiler) extractRateLimitConfig(frontmatter map[string]any) *RateLimit 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) } From c09defcd0171ea72b32f203a2c59f832fe97574e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:41:04 +0000 Subject: [PATCH 05/14] Fix action pins test to expect 39 pins Update TestGetActionPinsSorting to expect 39 pins (added docker/build-push-action@v6) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../src/content/docs/agent-factory-status.mdx | 1 + .../docs/reference/frontmatter-full.md | 21 +++++++++++++++++++ pkg/workflow/action_pins_test.go | 6 +++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/agent-factory-status.mdx b/docs/src/content/docs/agent-factory-status.mdx index 1e457203134..f0dffdf6107 100644 --- a/docs/src/content/docs/agent-factory-status.mdx +++ b/docs/src/content/docs/agent-factory-status.mdx @@ -142,6 +142,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 7a26eb63577..df46b2a4428 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -3498,6 +3498,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/workflow/action_pins_test.go b/pkg/workflow/action_pins_test.go index 9cbe8948a94..b7bfe74af25 100644 --- a/pkg/workflow/action_pins_test.go +++ b/pkg/workflow/action_pins_test.go @@ -297,9 +297,9 @@ func TestApplyActionPinToStep(t *testing.T) { func TestGetActionPinsSorting(t *testing.T) { pins := getActionPins() - // Verify we got all the pins (38 as of February 2026) - if len(pins) != 38 { - t.Errorf("getActionPins() returned %d pins, expected 38", len(pins)) + // Verify we got all the pins (39 as of February 2026) + if len(pins) != 39 { + t.Errorf("getActionPins() returned %d pins, expected 39", len(pins)) } // Verify they are sorted by version (descending) then by repository name (ascending) From e9171c39711baa727d9f8a8cdde9dffd3a363706 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:42:07 +0000 Subject: [PATCH 06/14] Add comprehensive rate limiting documentation - Document rate-limit configuration options - Explain how rate limiting works internally - Provide usage examples for different scenarios - Include troubleshooting guide - Add testing instructions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/RATE_LIMITING.md | 296 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 docs/RATE_LIMITING.md diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md new file mode 100644 index 00000000000..29cc39e6618 --- /dev/null +++ b/docs/RATE_LIMITING.md @@ -0,0 +1,296 @@ +# 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 # Maximum runs per time window (default: 5) + window: 60 # Time window in minutes (default: 60) + events: # Optional: specific events to limit + - workflow_dispatch + - issue_comment +--- +``` + +## Parameters + +### `max` (integer, optional) +- Maximum number of workflow runs allowed per user within the time window +- Default: 5 +- Range: 1-100 +- Example: `max: 10` allows 10 runs per window + +### `window` (integer, optional) +- Time window in minutes for rate limiting +- Default: 60 (1 hour) +- Range: 1-1440 (up to 24 hours) +- Example: `window: 30` creates a 30-minute window + +### `events` (array, optional) +- Specific event types to apply rate limiting to +- If not specified, applies to all programmatically triggered events +- 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 +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 + +### Basic Rate Limiting (Default) +```yaml +rate-limit: + max: 5 + window: 60 +``` +Allows 5 runs per hour for all programmatic events. + +### Strict Rate Limiting +```yaml +rate-limit: + max: 3 + window: 30 + events: [workflow_dispatch, issue_comment] +``` +Allows only 3 runs per 30 minutes for manual triggers and issue comments. + +### Generous Rate Limiting +```yaml +rate-limit: + max: 20 + window: 120 +``` +Allows 20 runs per 2 hours for all events. + +## 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 + ✓ 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", + "properties": { + "max": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 5 + }, + "window": { + "type": "integer", + "minimum": 1, + "maximum": 1440, + "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 + } + } +} +``` From 5b7584f3c0ae4dcdc23f2d473cfc59524f695197 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:54:07 +0000 Subject: [PATCH 07/14] Filter out cancelled workflow runs from rate limit calculation - Skip runs with status "cancelled" when counting toward rate limit - Add test case to verify cancelled runs are excluded - Update documentation to reflect this behavior - Cancelled runs are now explicitly logged as skipped Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_rate_limit.cjs | 6 ++++ actions/setup/js/check_rate_limit.test.cjs | 42 ++++++++++++++++++++++ docs/RATE_LIMITING.md | 2 ++ 3 files changed, 50 insertions(+) diff --git a/actions/setup/js/check_rate_limit.cjs b/actions/setup/js/check_rate_limit.cjs index 22b5dc5c0bd..c1e66fb98ce 100644 --- a/actions/setup/js/check_rate_limit.cjs +++ b/actions/setup/js/check_rate_limit.cjs @@ -104,6 +104,12 @@ async function main() { continue; } + // Skip cancelled workflow runs (they don't count toward the rate limit) + if (run.status === "cancelled") { + core.info(` Skipping run ${run.id} - cancelled (status: ${run.status})`); + continue; + } + // If specific events are configured, only count matching events const runEvent = run.event; if (limitedEvents.length > 0 && !limitedEvents.includes(runEvent)) { diff --git a/actions/setup/js/check_rate_limit.test.cjs b/actions/setup/js/check_rate_limit.test.cjs index 2cd09fb4048..8556f58df17 100644 --- a/actions/setup/js/check_rate_limit.test.cjs +++ b/actions/setup/js/check_rate_limit.test.cjs @@ -252,6 +252,48 @@ describe("check_rate_limit", () => { 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: "cancelled", + }, + { + 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: "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 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); diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md index 29cc39e6618..42e837e3bcb 100644 --- a/docs/RATE_LIMITING.md +++ b/docs/RATE_LIMITING.md @@ -66,6 +66,7 @@ rate-limit: - 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) 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 @@ -116,6 +117,7 @@ The rate limit check provides extensive logging: 📊 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 From 1b35433c58a12af50e9504d43a9eb6e1c5cd7ec2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:17:59 +0000 Subject: [PATCH 08/14] Reduce maximum rate limit window to 3 hours (180 minutes) - Update JSON schema: maximum window changed from 1440 to 180 minutes - Update documentation to reflect 3-hour maximum (was 24 hours) - Schema validation now rejects window values > 180 minutes - Test workflow with window: 30 still compiles successfully Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/RATE_LIMITING.md | 4 ++-- pkg/parser/schemas/main_workflow_schema.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md index 42e837e3bcb..4e449ee4f9c 100644 --- a/docs/RATE_LIMITING.md +++ b/docs/RATE_LIMITING.md @@ -40,7 +40,7 @@ rate-limit: ### `window` (integer, optional) - Time window in minutes for rate limiting - Default: 60 (1 hour) -- Range: 1-1440 (up to 24 hours) +- Range: 1-180 (up to 3 hours) - Example: `window: 30` creates a 30-minute window ### `events` (array, optional) @@ -273,7 +273,7 @@ The rate-limit field is validated against this JSON schema: "window": { "type": "integer", "minimum": 1, - "maximum": 1440, + "maximum": 180, "default": 60 }, "events": { diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 4e47e23662a..119ceba7d4e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6089,9 +6089,9 @@ "window": { "type": "integer", "minimum": 1, - "maximum": 1440, + "maximum": 180, "default": 60, - "description": "Time window in minutes for rate limiting. Defaults to 60 (1 hour)." + "description": "Time window in minutes for rate limiting. Defaults to 60 (1 hour). Maximum: 180 (3 hours)." }, "events": { "type": "array", From 9c810029c099b3e737784b465b777656e37a9e67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:35:16 +0000 Subject: [PATCH 09/14] Changes before error encountered Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_rate_limit.cjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/actions/setup/js/check_rate_limit.cjs b/actions/setup/js/check_rate_limit.cjs index c1e66fb98ce..afd37e252a9 100644 --- a/actions/setup/js/check_rate_limit.cjs +++ b/actions/setup/js/check_rate_limit.cjs @@ -110,6 +110,18 @@ async function main() { 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 - runStart) / 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)) { From f64a1eec0a488524ccc870abcd25b7331cb6b16d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:33:54 +0000 Subject: [PATCH 10/14] Make rate-limit.max required (range 1-10) and add 15s duration filter Schema changes: - Make 'max' field required (no longer optional) - Change max range from 1-100 to 1-10 - Remove default value from max (now required) - Keep window default at 60 minutes JavaScript implementation: - Skip runs that completed in less than 15 seconds - Calculate duration using created_at and updated_at timestamps - Log skipped short-duration runs - Add test case for 15-second threshold Documentation updates: - Mark max as required in all docs - Update range from 1-100 to 1-10 - Document 15-second duration filter - Update schema definition section Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/test-rate-limit.lock.yml | 4 +-- actions/setup/js/check_rate_limit.cjs | 4 +-- actions/setup/js/check_rate_limit.test.cjs | 37 ++++++++++++++++++++ docs/RATE_LIMITING.md | 12 +++---- pkg/parser/schemas/main_workflow_schema.json | 6 ++-- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-rate-limit.lock.yml b/.github/workflows/test-rate-limit.lock.yml index 3f3e6a087d5..1ef57cbe77b 100644 --- a/.github/workflows/test-rate-limit.lock.yml +++ b/.github/workflows/test-rate-limit.lock.yml @@ -207,7 +207,7 @@ jobs: export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.0' mkdir -p /home/runner/.copilot - cat << MCPCONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { "mcpServers": { "github": { @@ -228,7 +228,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - MCPCONFIG_EOF + GH_AW_MCP_CONFIG_EOF - name: Generate workflow overview uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: diff --git a/actions/setup/js/check_rate_limit.cjs b/actions/setup/js/check_rate_limit.cjs index afd37e252a9..bc9645b1142 100644 --- a/actions/setup/js/check_rate_limit.cjs +++ b/actions/setup/js/check_rate_limit.cjs @@ -114,8 +114,8 @@ async function main() { if (run.created_at && run.updated_at) { const runStart = new Date(run.created_at); const runEnd = new Date(run.updated_at); - const durationSeconds = (runEnd - runStart) / 1000; - + 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; diff --git a/actions/setup/js/check_rate_limit.test.cjs b/actions/setup/js/check_rate_limit.test.cjs index 8556f58df17..3ad52187941 100644 --- a/actions/setup/js/check_rate_limit.test.cjs +++ b/actions/setup/js/check_rate_limit.test.cjs @@ -294,6 +294,43 @@ describe("check_rate_limit", () => { 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); diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md index 4e449ee4f9c..de018728af1 100644 --- a/docs/RATE_LIMITING.md +++ b/docs/RATE_LIMITING.md @@ -31,11 +31,10 @@ rate-limit: ## Parameters -### `max` (integer, optional) +### `max` (integer, **required**) - Maximum number of workflow runs allowed per user within the time window -- Default: 5 -- Range: 1-100 -- Example: `max: 10` allows 10 runs per window +- Range: 1-10 +- Example: `max: 5` allows 5 runs per window ### `window` (integer, optional) - Time window in minutes for rate limiting @@ -67,6 +66,7 @@ rate-limit: - 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 @@ -263,12 +263,12 @@ The rate-limit field is validated against this JSON schema: ```json { "type": "object", + "required": ["max"], "properties": { "max": { "type": "integer", "minimum": 1, - "maximum": 100, - "default": 5 + "maximum": 10 }, "window": { "type": "integer", diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 279a7935ed0..26fafcdde08 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6090,13 +6090,13 @@ "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": 100, - "default": 5, - "description": "Maximum number of workflow runs allowed per user within the time window. Defaults to 5." + "maximum": 10, + "description": "Maximum number of workflow runs allowed per user within the time window. Required field." }, "window": { "type": "integer", From 396a26ccaafe0705160068596d79adf07e2607f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:16:38 +0000 Subject: [PATCH 11/14] Fix critical issues: workflow ID, conclusion field, and permissions Critical fixes based on code review: 1. Use workflow file from GITHUB_WORKFLOW_REF instead of workflow name - Parse workflow file from GITHUB_WORKFLOW_REF environment variable - Fall back to workflow name if parsing fails - Fixes API calls to listWorkflowRuns 2. Check run.conclusion instead of run.status for cancelled runs - GitHub uses conclusion: 'cancelled' with status: 'completed' - Update check from run.status === 'cancelled' to run.conclusion === 'cancelled' - Update test fixtures to use correct fields 3. Add actions: read permission to pre-activation job - Required for querying workflow runs via GitHub API - Added when rate-limit is configured 4. Add programmatic events filter when no events specified - Only apply rate limiting to known programmatic triggers - Skip for non-programmatic events (push, schedule, etc.) - Prevents unintended rate limiting 5. Quote GH_AW_RATE_LIMIT_EVENTS environment variable - Improves YAML robustness and consistency Tests: - Added 3 new test cases (18 total, all passing) - Test programmatic events filtering - Test workflow ID resolution from GITHUB_WORKFLOW_REF - Test fallback behavior Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/test-rate-limit.lock.yml | 3 +- actions/setup/js/check_rate_limit.cjs | 37 ++++++++++++++-- actions/setup/js/check_rate_limit.test.cjs | 50 +++++++++++++++++++++- pkg/workflow/compiler_activation_jobs.go | 8 ++++ pkg/workflow/role_checks.go | 2 +- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-rate-limit.lock.yml b/.github/workflows/test-rate-limit.lock.yml index 1ef57cbe77b..dce14f954b4 100644 --- a/.github/workflows/test-rate-limit.lock.yml +++ b/.github/workflows/test-rate-limit.lock.yml @@ -473,6 +473,7 @@ jobs: pre_activation: runs-on: ubuntu-slim permissions: + actions: read contents: read outputs: activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} @@ -505,7 +506,7 @@ jobs: env: GH_AW_RATE_LIMIT_MAX: "3" GH_AW_RATE_LIMIT_WINDOW: "30" - GH_AW_RATE_LIMIT_EVENTS: workflow_dispatch,issue_comment + GH_AW_RATE_LIMIT_EVENTS: "workflow_dispatch,issue_comment" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/actions/setup/js/check_rate_limit.cjs b/actions/setup/js/check_rate_limit.cjs index bc9645b1142..67773b91860 100644 --- a/actions/setup/js/check_rate_limit.cjs +++ b/actions/setup/js/check_rate_limit.cjs @@ -10,10 +10,27 @@ async function main() { const actor = context.actor; const owner = context.repo.owner; const repo = context.repo.repo; - const workflowId = context.workflow; 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); @@ -36,7 +53,18 @@ async function main() { } core.info(` Event '${eventName}' is subject to rate limiting`); } else { - core.info(` Rate limiting applies to all programmatically triggered events`); + // 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 @@ -105,8 +133,9 @@ async function main() { } // Skip cancelled workflow runs (they don't count toward the rate limit) - if (run.status === "cancelled") { - core.info(` Skipping run ${run.id} - cancelled (status: ${run.status})`); + // 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; } diff --git a/actions/setup/js/check_rate_limit.test.cjs b/actions/setup/js/check_rate_limit.test.cjs index 3ad52187941..765b763ba76 100644 --- a/actions/setup/js/check_rate_limit.test.cjs +++ b/actions/setup/js/check_rate_limit.test.cjs @@ -264,7 +264,8 @@ describe("check_rate_limit", () => { created_at: recentTime.toISOString(), actor: { login: "test-user" }, event: "workflow_dispatch", - status: "cancelled", + status: "completed", + conclusion: "cancelled", }, { id: 222222, @@ -273,6 +274,7 @@ describe("check_rate_limit", () => { actor: { login: "test-user" }, event: "workflow_dispatch", status: "completed", + conclusion: "success", }, { id: 333333, @@ -280,7 +282,8 @@ describe("check_rate_limit", () => { created_at: recentTime.toISOString(), actor: { login: "test-user" }, event: "workflow_dispatch", - status: "cancelled", + status: "completed", + conclusion: "cancelled", }, ], }, @@ -512,4 +515,47 @@ describe("check_rate_limit", () => { 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/pkg/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go index a2fe66abf23..813d45238c1 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() diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index 938e3659572..a1328200115 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -62,7 +62,7 @@ func (c *Compiler) generateRateLimitCheck(data *WorkflowData, steps []string) [] // Set events to check (if specified) if len(data.RateLimit.Events) > 0 { - steps = append(steps, fmt.Sprintf(" GH_AW_RATE_LIMIT_EVENTS: %s\n", strings.Join(data.RateLimit.Events, ","))) + steps = append(steps, fmt.Sprintf(" GH_AW_RATE_LIMIT_EVENTS: %q\n", strings.Join(data.RateLimit.Events, ","))) } steps = append(steps, " with:\n") From dbaa63add1e0c57312408f26dea779d11e6e6983 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:35:00 +0000 Subject: [PATCH 12/14] Apply rate limiting to agentic workflows with issue triggers - Add rate-limit (max: 5, window: 60) to ai-moderator.md (issues, issue_comment) - Add rate-limit (max: 5, window: 60) to auto-triage-issues.md (issues) - Add rate-limit (max: 5, window: 60) to example-custom-error-patterns.md (issues) - Add rate-limit (max: 5, window: 60) to workflow-generator.md (issues) - Remove test-rate-limit.md and its lock file - Recompile all 148 workflows successfully - actions: read permission automatically added to pre-activation jobs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ai-moderator.lock.yml | 19 +- .github/workflows/ai-moderator.md | 4 + .github/workflows/auto-triage-issues.lock.yml | 19 +- .github/workflows/auto-triage-issues.md | 4 + .../example-custom-error-patterns.lock.yml | 19 +- .../example-custom-error-patterns.md | 5 +- .github/workflows/test-rate-limit.lock.yml | 517 ------------------ .github/workflows/test-rate-limit.md | 16 - .github/workflows/workflow-generator.lock.yml | 19 +- .github/workflows/workflow-generator.md | 4 + 10 files changed, 84 insertions(+), 542 deletions(-) delete mode 100644 .github/workflows/test-rate-limit.lock.yml delete mode 100644 .github/workflows/test-rate-limit.md diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index 852d7cfa4d8..d07f42bc482 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: 2c0c2d09eb172ff1b3867c0298c4c50def936a66aac1b14996a196ea48662c2f 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: "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 944104a304e..71833c241b7 100644 --- a/.github/workflows/ai-moderator.md +++ b/.github/workflows/ai-moderator.md @@ -14,6 +14,10 @@ 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 + events: [issues, issue_comment] 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 9c7b0764365..68502badb4a 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: 948ba3839d6083bd933117ad41c2410fd690889345d83f761c8c387389deeee8 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" + 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 b4a63cff50b..f5e58a6cedf 100644 --- a/.github/workflows/auto-triage-issues.md +++ b/.github/workflows/auto-triage-issues.md @@ -5,6 +5,10 @@ on: issues: types: [opened, edited] schedule: every 6h +rate-limit: + max: 5 + window: 60 + events: [issues] 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 11f4a54309d..c2870e54c40 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: 01403571c2bd8d2864e2d8ad6a80c019b48dfaf1ac2b61ee2f35c9a19145fd5d 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 e170273716c..546b02fb068 100644 --- a/.github/workflows/example-custom-error-patterns.md +++ b/.github/workflows/example-custom-error-patterns.md @@ -2,7 +2,10 @@ on: issues: types: [opened] - +rate-limit: + max: 5 + window: 60 + events: [issues] permissions: contents: read issues: read diff --git a/.github/workflows/test-rate-limit.lock.yml b/.github/workflows/test-rate-limit.lock.yml deleted file mode 100644 index dce14f954b4..00000000000 --- a/.github/workflows/test-rate-limit.lock.yml +++ /dev/null @@ -1,517 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw. DO NOT EDIT. -# -# To update this file, edit the corresponding .md file and run: -# gh aw compile -# For more information: https://github.com/github/gh-aw/blob/main/.github/aw/github-agentic-workflows.md -# -# -# frontmatter-hash: 19edebfa0f1d89f75903ffc8672771a2febe1ddd248592762851b1fcedc5f158 - -name: "Test Rate Limiting" -"on": - issue_comment: - types: - - created - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}" - -run-name: "Test Rate Limiting" - -jobs: - activation: - needs: pre_activation - if: needs.pre_activation.outputs.activated == 'true' - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - steps: - - name: Checkout actions folder - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - actions - persist-credentials: false - - name: Setup Scripts - uses: ./actions/setup - with: - destination: /opt/gh-aw/actions - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "test-rate-limit.lock.yml" - with: - 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_workflow_timestamp_api.cjs'); - await main(); - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - model: ${{ steps.generate_aw_info.outputs.model }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Checkout actions folder - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - actions - persist-credentials: false - - name: Setup Scripts - uses: ./actions/setup - with: - destination: /opt/gh-aw/actions - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - github.event.pull_request - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_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/checkout_pr_branch.cjs'); - await main(); - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "copilot", - engine_name: "GitHub Copilot CLI", - model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", - version: "", - agent_version: "0.0.406", - workflow_name: "Test Rate Limiting", - experimental: false, - supports_tools_allowlist: true, - supports_http_transport: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - allowed_domains: ["defaults"], - firewall_enabled: true, - awf_version: "v0.14.0", - awmg_version: "", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() - }; - - // Write to /tmp/gh-aw directory to avoid inclusion in PR - const tmpPath = '/tmp/gh-aw/aw_info.json'; - fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); - console.log('Generated aw_info.json at:', tmpPath); - console.log(JSON.stringify(awInfo, null, 2)); - - // Set model as output for reuse in other steps/jobs - core.setOutput('model', awInfo.model); - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.406 - - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.14.0 - - name: Determine automatic lockdown mode for GitHub MCP server - id: determine-automatic-lockdown - env: - TOKEN_CHECK: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - if: env.TOKEN_CHECK != '' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); - await determineAutomaticLockdown(github, context, core); - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.14.0 ghcr.io/github/gh-aw-firewall/squid:0.14.0 ghcr.io/github/gh-aw-mcpg:v0.1.0 ghcr.io/github/github-mcp-server:v0.30.3 - - name: Start MCP gateway - id: start-mcp-gateway - env: - GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export DEBUG="*" - - export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.0' - - mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v0.30.3", - "env": { - "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", - "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Generate workflow overview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); - await generateWorkflowOverview(core); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || '' }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - cat << 'PROMPT_EOF' > "$GH_AW_PROMPT" - - PROMPT_EOF - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - PROMPT_EOF - if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then - cat "/opt/gh-aw/prompts/pr_context_prompt.md" >> "$GH_AW_PROMPT" - fi - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - - PROMPT_EOF - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - {{#runtime-import .github/workflows/test-rate-limit.md}} - PROMPT_EOF - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || '' }} - with: - script: | - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, - GH_AW_IS_PR_COMMENT: process.env.GH_AW_IS_PR_COMMENT - } - }); - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Clean git credentials - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute GitHub Copilot CLI - id: agentic_execution - # Copilot CLI tool arguments (sorted): - timeout-minutes: 20 - run: | - set -o pipefail - sudo -E awf --enable-chroot --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.14.0 --skip-pull \ - -- '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' \ - 2>&1 | tee /tmp/gh-aw/agent-stdio.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json - GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - GITHUB_WORKSPACE: ${{ github.workspace }} - XDG_CONFIG_HOME: /home/runner - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Copy Copilot session state files to logs - if: always() - continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi - - name: Stop MCP gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' - SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload engine output files - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: agent_outputs - path: | - /tmp/gh-aw/sandbox/agent/logs/ - /tmp/gh-aw/redacted-urls.log - if-no-files-found: ignore - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); - await main(); - - name: Parse MCP gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: agent-artifacts - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - if-no-files-found: ignore - - pre_activation: - runs-on: ubuntu-slim - permissions: - actions: read - contents: read - outputs: - 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 - with: - sparse-checkout: | - actions - persist-credentials: false - - name: Setup Scripts - uses: ./actions/setup - with: - destination: /opt/gh-aw/actions - - name: Check team membership for workflow - id: check_membership - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_REQUIRED_ROLES: admin,maintainer,write - 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_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: "3" - GH_AW_RATE_LIMIT_WINDOW: "30" - GH_AW_RATE_LIMIT_EVENTS: "workflow_dispatch,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(); - diff --git a/.github/workflows/test-rate-limit.md b/.github/workflows/test-rate-limit.md deleted file mode 100644 index 472e5831a98..00000000000 --- a/.github/workflows/test-rate-limit.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -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 for workflow_dispatch and issue_comment events. diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index 62efef60fb2..43182e0ca83 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: 6715100ea6afc52b4d7cf99e50cca3e199f957dd0125e9f61572902be1b0a14d 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 9d48a0a7837..93fa607acbc 100644 --- a/.github/workflows/workflow-generator.md +++ b/.github/workflows/workflow-generator.md @@ -5,6 +5,10 @@ on: types: [opened] lock-for-agent: true reaction: "eyes" +rate-limit: + max: 5 + window: 60 + events: [issues] permissions: contents: read issues: read From 9c9fc7de22751710ad5e8b4bede81c4ed7e88d12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:01:56 +0000 Subject: [PATCH 13/14] Make rate-limit.events optional with automatic inference - Add inferEventsFromTriggers() to automatically detect programmatic triggers - Infer events from workflow's 'on:' section when not explicitly specified - Only programmatic triggers are inferred (issues, issue_comment, workflow_dispatch, etc.) - Non-programmatic triggers (push, schedule, etc.) are filtered out - Remove explicit events from production workflows (now inferred automatically) - Update documentation to explain automatic inference - Add comprehensive tests for event inference (5 test cases, all passing) Production workflows updated: - ai-moderator: infers [issues, issue_comment, workflow_dispatch] - auto-triage-issues: infers [issues, workflow_dispatch] - example-custom-error-patterns: infers [issues] - workflow-generator: infers [issues] All 148 workflows recompiled successfully with automatic event inference. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ai-moderator.lock.yml | 4 +- .github/workflows/ai-moderator.md | 1 - .github/workflows/auto-triage-issues.lock.yml | 4 +- .github/workflows/auto-triage-issues.md | 1 - .../example-custom-error-patterns.lock.yml | 2 +- .../example-custom-error-patterns.md | 1 - .github/workflows/workflow-generator.lock.yml | 2 +- .github/workflows/workflow-generator.md | 1 - docs/RATE_LIMITING.md | 38 ++++++--- pkg/workflow/role_checks.go | 50 +++++++++++ pkg/workflow/role_checks_test.go | 85 +++++++++++++++++++ 11 files changed, 167 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index d07f42bc482..a2074a41e02 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: 2c0c2d09eb172ff1b3867c0298c4c50def936a66aac1b14996a196ea48662c2f +# frontmatter-hash: ae515f3b3a547d9b86e5347378b5d34ce798ff0eb460bd6c912305e358b305c1 name: "AI Moderator" "on": @@ -1025,7 +1025,7 @@ jobs: env: GH_AW_RATE_LIMIT_MAX: "5" GH_AW_RATE_LIMIT_WINDOW: "60" - GH_AW_RATE_LIMIT_EVENTS: "issues,issue_comment" + GH_AW_RATE_LIMIT_EVENTS: "workflow_dispatch,issues,issue_comment" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/ai-moderator.md b/.github/workflows/ai-moderator.md index 71833c241b7..46850ac9e0a 100644 --- a/.github/workflows/ai-moderator.md +++ b/.github/workflows/ai-moderator.md @@ -17,7 +17,6 @@ on: rate-limit: max: 5 window: 60 - events: [issues, issue_comment] 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 68502badb4a..08f3259fbb3 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: 948ba3839d6083bd933117ad41c2410fd690889345d83f761c8c387389deeee8 +# frontmatter-hash: 9c6fbb9920281fff3373c0db01b6a5e5fe93b637d2902977763c0634733725be name: "Auto-Triage Issues" "on": @@ -1082,7 +1082,7 @@ jobs: env: GH_AW_RATE_LIMIT_MAX: "5" GH_AW_RATE_LIMIT_WINDOW: "60" - GH_AW_RATE_LIMIT_EVENTS: "issues" + GH_AW_RATE_LIMIT_EVENTS: "issues,workflow_dispatch" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/auto-triage-issues.md b/.github/workflows/auto-triage-issues.md index f5e58a6cedf..89bbdaed523 100644 --- a/.github/workflows/auto-triage-issues.md +++ b/.github/workflows/auto-triage-issues.md @@ -8,7 +8,6 @@ on: rate-limit: max: 5 window: 60 - events: [issues] 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 c2870e54c40..57ae4344403 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: 01403571c2bd8d2864e2d8ad6a80c019b48dfaf1ac2b61ee2f35c9a19145fd5d +# frontmatter-hash: 5fb42e71fdea5fafa3ea342aac01e6373a703bf85c191fe80ab16d873ee25131 name: "Example: Custom Error Patterns" "on": diff --git a/.github/workflows/example-custom-error-patterns.md b/.github/workflows/example-custom-error-patterns.md index 546b02fb068..4d45390e492 100644 --- a/.github/workflows/example-custom-error-patterns.md +++ b/.github/workflows/example-custom-error-patterns.md @@ -5,7 +5,6 @@ on: rate-limit: max: 5 window: 60 - events: [issues] permissions: contents: read issues: read diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index 43182e0ca83..372090bb92b 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: 6715100ea6afc52b4d7cf99e50cca3e199f957dd0125e9f61572902be1b0a14d +# frontmatter-hash: 1bf8a53ac78955ad6bc31224322cd512e946710820a359cc08613e147865408c name: "Workflow Generator" "on": diff --git a/.github/workflows/workflow-generator.md b/.github/workflows/workflow-generator.md index 93fa607acbc..c6ca7df0cfb 100644 --- a/.github/workflows/workflow-generator.md +++ b/.github/workflows/workflow-generator.md @@ -8,7 +8,6 @@ on: rate-limit: max: 5 window: 60 - events: [issues] permissions: contents: read issues: read diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md index de018728af1..f3863120847 100644 --- a/docs/RATE_LIMITING.md +++ b/docs/RATE_LIMITING.md @@ -21,11 +21,9 @@ on: issue_comment: types: [created] rate-limit: - max: 5 # Maximum runs per time window (default: 5) - window: 60 # Time window in minutes (default: 60) - events: # Optional: specific events to limit - - workflow_dispatch - - issue_comment + max: 5 # Required: 1-10 runs + window: 60 # Optional: minutes (default 60, max 180) + # events field is optional - automatically inferred from 'on:' triggers --- ``` @@ -44,7 +42,9 @@ rate-limit: ### `events` (array, optional) - Specific event types to apply rate limiting to -- If not specified, applies to all programmatically triggered events +- **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` @@ -72,30 +72,44 @@ rate-limit: ## Examples -### Basic Rate Limiting (Default) +### 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] ``` -Allows 5 runs per hour for all programmatic events. +Events are automatically inferred from the workflow's triggers. Simplest configuration. -### Strict Rate Limiting +### 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] ``` -Allows only 3 runs per 30 minutes for manual triggers and issue comments. +Explicitly specify events to override inference. Allows only 3 runs per 30 minutes for the specified events. ### Generous Rate Limiting ```yaml rate-limit: - max: 20 + max: 10 window: 120 ``` -Allows 20 runs per 2 hours for all events. +Allows 10 runs per 2 hours. Events inferred from triggers. ## Behavior Details diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index a1328200115..2446f1e9b27 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -186,6 +186,12 @@ func (c *Compiler) extractRateLimitConfig(frontmatter map[string]any) *RateLimit 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) @@ -196,6 +202,50 @@ func (c *Compiler) extractRateLimitConfig(frontmatter map[string]any) *RateLimit 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 3bae6859e1e..2065f13dd68 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") + } + }) + } +} From 907aab7f7d34160bc7a779b0e99560b2ee8de226 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Feb 2026 21:49:17 +0000 Subject: [PATCH 14/14] Add changeset [skip-ci] --- .changeset/patch-rate-limit-programmatic-events.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-rate-limit-programmatic-events.md diff --git a/.changeset/patch-rate-limit-programmatic-events.md b/.changeset/patch-rate-limit-programmatic-events.md new file mode 100644 index 00000000000..cd2d5ac6418 --- /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.