Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion actions/setup/js/check_rate_limit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const { fetchAndLogRateLimit } = require("./github_rate_limit_logger.cjs");
* Prevents users from triggering workflows too frequently
*/

const PROGRAMMATIC_EVENTS = ["workflow_dispatch", "repository_dispatch", "issue_comment", "pull_request_review", "pull_request_review_comment", "discussion_comment"];
// Keep in sync with pkg/workflow/role_checks.go and the user-rate-limit.events schema enum.
const PROGRAMMATIC_EVENTS = ["discussion", "discussion_comment", "issue_comment", "issues", "pull_request", "pull_request_review", "pull_request_review_comment", "repository_dispatch", "workflow_dispatch"];

async function main() {
const {
Expand Down
10 changes: 7 additions & 3 deletions actions/setup/js/check_rate_limit.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -751,14 +751,18 @@ describe("check_rate_limit", () => {
expect(mockCore.setOutput).toHaveBeenCalledWith("rate_limit_ok", "true");
});

it("should skip non-programmatic events like pull_request by default", async () => {
it("should apply rate limiting to pull_request events by default", async () => {
mockContext.eventName = "pull_request";

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("Event 'pull_request' is not a programmatic trigger"));
expect(mockGithub.rest.actions.listWorkflowRuns).not.toHaveBeenCalled();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Rate limiting applies to programmatic events"));
expect(mockGithub.rest.actions.listWorkflowRuns).toHaveBeenCalled();
});

it("should log stack trace for errors that have one", async () => {
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -22002,8 +22002,8 @@ user-rate-limit:
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).
# limiting is inferred from the workflow triggers; if no supported programmatic
# triggers are found, it falls back to all supported programmatic events.
# (optional)
events: []
# Array of strings
Expand Down
6 changes: 3 additions & 3 deletions docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,21 +499,21 @@ max-daily-ai-credits: -1

### Per-User Rate Limiting (`user-rate-limit:`)

Limits how frequently a single user can trigger the workflow. When the limit is exceeded, the pre-activation job cancels the run before the agent executes. Rate limiting applies to programmatically triggered events (such as `workflow_dispatch`, `issue_comment`, and `pull_request_review`); when `events` is omitted, the applicable events are inferred from the `on:` section.
Limits how frequently a single user can trigger the workflow. When the limit is exceeded, the pre-activation job cancels the run before the agent executes. Rate limiting applies to programmatically triggered events (such as `workflow_dispatch`, `issue_comment`, and `pull_request_review`); when `events` is omitted, the applicable events are inferred from the `on:` section, falling back to all supported programmatic events if no supported triggers are found.

```yaml wrap
user-rate-limit:
max-runs-per-window: 5 # Required: maximum runs per user per window (1-10)
window: 60 # Optional: window in minutes (default: 60, max: 180)
events: [workflow_dispatch, issue_comment] # Optional: events to rate limit (inferred from `on:` when omitted)
events: [workflow_dispatch, issue_comment] # Optional: events to rate limit (inferred from `on:` when omitted; fallback to all supported programmatic events)
ignored-roles: [admin, maintain] # Optional: exempt roles (default: [admin, maintain, write])
```

`max-runs-per-window` also supports a GitHub Actions expression (for example `${{ inputs.max-runs-per-window }}`) that resolves to an integer at runtime.

Users with any of the `ignored-roles` are not rate limited. The default exemptions are `admin`, `maintain`, and `write`; set `ignored-roles: []` to rate limit every user, including administrators.

Legacy frontmatter that used a top-level `rate-limit:` section, or `max:`/`max-runs:` instead of `max-runs-per-window:`, is migrated automatically by `gh aw fix`.
Legacy frontmatter that used a top-level `rate-limit:` section, or `max:`/`max-runs:` instead of `max-runs-per-window:`, must be migrated with `gh aw fix`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by the maintainer directive and addressed in 9c379f3: the docs require gh aw fix migration rather than documenting accepted aliases.


See [Rate Limiting and Controls](/gh-aw/reference/rate-limiting-controls/) for more details.

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/rate-limiting-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ The `user-rate-limit` frontmatter field prevents users from triggering workflows
user-rate-limit:
max-runs-per-window: 5 # Required: Maximum runs per window (1-10)
window: 60 # Optional: Time window in minutes (default: 60, max: 180)
events: [workflow_dispatch, issue_comment] # Optional: Specific events (auto-inferred if omitted)
events: [workflow_dispatch, issue_comment] # Optional: Specific events (inferred from `on:` when omitted; fallback to all supported programmatic events)
ignored-roles: [admin, maintain] # Optional: Roles exempt from rate limiting (default: [admin, maintain, write])
```

Expand Down
62 changes: 62 additions & 0 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,68 @@ func TestValidateMainWorkflowFrontmatter_Plugins(t *testing.T) {
}
}

func TestValidateMainWorkflowFrontmatter_UserRateLimitMaxField(t *testing.T) {
t.Parallel()

for _, tt := range []struct {
name string
rateLimit map[string]any
wantErr bool
errContains string
}{
{
name: "canonical max-runs-per-window",
rateLimit: map[string]any{
"max-runs-per-window": 5,
},
},
{
name: "legacy max-runs alias",
rateLimit: map[string]any{"max-runs": 5},
wantErr: true,
errContains: "Unknown property: max-runs",
Comment on lines +131 to +134

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by the maintainer directive and addressed in 9c379f3: legacy aliases are schema-invalid in favor of codemod migration.

},
{
name: "legacy max alias",
rateLimit: map[string]any{"max": 5},
wantErr: true,
errContains: "Unknown property: max",
},
{
name: "legacy max-runs expression alias",
rateLimit: map[string]any{"max-runs": "${{ inputs.max_runs }}"},
wantErr: true,
errContains: "Unknown property: max-runs",
},
{
name: "missing max field",
rateLimit: map[string]any{"window": 60},
wantErr: true,
},
{
name: "unknown nested field",
rateLimit: map[string]any{"max-runs-per-window": 5, "limit": 5},
wantErr: true,
errContains: "Unknown property: limit",
},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(map[string]any{
"on": "workflow_dispatch",
"user-rate-limit": tt.rateLimit,
}, "workflow.md")
if (err != nil) != tt.wantErr {
t.Fatalf("validation error = %v, wantErr %t", err, tt.wantErr)
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("validation error = %v, want substring %q", err, tt.errContains)
}
})
}
}

func TestValidateMainWorkflowFrontmatterEnclaves(t *testing.T) {
valid := map[string]any{
"on": "workflow_dispatch",
Expand Down
2 changes: 1 addition & 1 deletion pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12371,7 +12371,7 @@
},
"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).",
"description": "Optional list of event types to apply rate limiting to. If not specified, rate limiting is inferred from the workflow triggers; if no supported programmatic triggers are found, it falls back to all supported programmatic events.",
"items": {
"type": "string",
"enum": ["workflow_dispatch", "issue_comment", "pull_request_review", "pull_request_review_comment", "issues", "pull_request", "discussion_comment", "discussion", "repository_dispatch"]
Expand Down
Loading
Loading