diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml
index 6d1dc6591b7..17af7991d1c 100644
--- a/.github/workflows/ai-moderator.lock.yml
+++ b/.github/workflows/ai-moderator.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"bd87a19728538bbfd5a9a8baa7c312565b9b2669ba9d4b5dc7300c589e3e52aa","strict":true,"agent_id":"codex"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"11495e49db170a0da6c3a10508d85fa389fc6ae3430ad11604abbed3cfdbc3e7","strict":true,"agent_id":"codex"}
# gh-aw-manifest: {"version":1,"secrets":["CODEX_API_KEY","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_ENDPOINT","GH_AW_OTEL_HEADERS","GITHUB_TOKEN","OPENAI_API_KEY"],"actions":[{"repo":"actions/cache","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.44"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.44"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.44"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.6","digest":"sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c"},{"image":"ghcr.io/github/github-mcp-server:v1.0.3","digest":"sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959","pinned_image":"ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
# ___ _ _
# / _ \ | | (_)
@@ -67,6 +67,19 @@ name: "AI Moderator"
types:
- opened
# roles: all # Roles processed as role check in pre-activation job
+ # skip-author-associations: # Skip-author-associations compiled into pre-activation job if condition
+ # issue_comment:
+ # - owner
+ # - member
+ # - collaborator
+ # issues:
+ # - owner
+ # - member
+ # - collaborator
+ # pull_request:
+ # - owner
+ # - member
+ # - collaborator
# skip-bots: # Skip-bots processed as bot check in pre-activation job
# - github-actions # Skip-bots processed as bot check in pre-activation job
# - copilot # Skip-bots processed as bot check in pre-activation job
@@ -244,21 +257,21 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
- cat << 'GH_AW_PROMPT_af6fcd290c4310a9_EOF'
+ cat << 'GH_AW_PROMPT_7791412ef51bdc90_EOF'
- GH_AW_PROMPT_af6fcd290c4310a9_EOF
+ GH_AW_PROMPT_7791412ef51bdc90_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_af6fcd290c4310a9_EOF'
+ cat << 'GH_AW_PROMPT_7791412ef51bdc90_EOF'
Tools: add_labels, hide_comment(max:5), missing_tool, missing_data, noop
- GH_AW_PROMPT_af6fcd290c4310a9_EOF
+ GH_AW_PROMPT_7791412ef51bdc90_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_af6fcd290c4310a9_EOF'
+ cat << 'GH_AW_PROMPT_7791412ef51bdc90_EOF'
The following GitHub context information is available for this workflow:
{{#if __GH_AW_GITHUB_ACTOR__ }}
@@ -287,14 +300,14 @@ jobs:
{{/if}}
- GH_AW_PROMPT_af6fcd290c4310a9_EOF
+ GH_AW_PROMPT_7791412ef51bdc90_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_af6fcd290c4310a9_EOF'
+ cat << 'GH_AW_PROMPT_7791412ef51bdc90_EOF'
{{#runtime-import .github/workflows/shared/observability-otlp.md}}
{{#runtime-import .github/workflows/shared/noop-reminder.md}}
{{#runtime-import .github/workflows/ai-moderator.md}}
- GH_AW_PROMPT_af6fcd290c4310a9_EOF
+ GH_AW_PROMPT_7791412ef51bdc90_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -511,9 +524,9 @@ jobs:
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_46e8e8e4c5ed8b87_EOF'
+ cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_33bb055c68c7cead_EOF'
{"add_labels":{"allowed":["spam","ai-generated","link-spam","ai-inspected"],"target":"*"},"create_report_incomplete_issue":{},"hide_comment":{"allowed_reasons":["spam"],"max":5},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_46e8e8e4c5ed8b87_EOF
+ GH_AW_SAFE_OUTPUTS_CONFIG_33bb055c68c7cead_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
@@ -728,7 +741,7 @@ jobs:
DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0')
export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -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_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -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 -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GITHUB_AW_OTEL_TRACE_ID -e GITHUB_AW_OTEL_PARENT_SPAN_ID -e CODEX_HOME -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.3.6'
- cat > "${RUNNER_TEMP}/gh-aw/mcp-config/config.toml" << GH_AW_MCP_CONFIG_ce7fcfe9fe9cae6e_EOF
+ cat > "${RUNNER_TEMP}/gh-aw/mcp-config/config.toml" << GH_AW_MCP_CONFIG_93ebdd8cd39a5934_EOF
[history]
persistence = "none"
@@ -755,11 +768,11 @@ jobs:
[mcp_servers.safeoutputs."guard-policies".write-sink]
accept = ["*"]
- GH_AW_MCP_CONFIG_ce7fcfe9fe9cae6e_EOF
+ GH_AW_MCP_CONFIG_93ebdd8cd39a5934_EOF
# Generate JSON config for MCP gateway
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_ce7fcfe9fe9cae6e_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
+ cat << GH_AW_MCP_CONFIG_93ebdd8cd39a5934_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
{
"mcpServers": {
"github": {
@@ -808,11 +821,11 @@ jobs:
}
}
}
- GH_AW_MCP_CONFIG_ce7fcfe9fe9cae6e_EOF
+ GH_AW_MCP_CONFIG_93ebdd8cd39a5934_EOF
# Sync converter output to writable CODEX_HOME for Codex
mkdir -p /tmp/gh-aw/mcp-config
- cat > "/tmp/gh-aw/mcp-config/config.toml" << GH_AW_CODEX_SHELL_POLICY_2506e781fc328bb2_EOF
+ cat > "/tmp/gh-aw/mcp-config/config.toml" << GH_AW_CODEX_SHELL_POLICY_5b66884ab5a60c7f_EOF
model_provider = "openai-proxy"
@@ -824,7 +837,7 @@ jobs:
[shell_environment_policy]
inherit = "core"
include_only = ["CODEX_API_KEY", "GH_AW_ASSETS_ALLOWED_EXTS", "GH_AW_ASSETS_BRANCH", "GH_AW_ASSETS_MAX_SIZE_KB", "GH_AW_SAFE_OUTPUTS", "GITHUB_PERSONAL_ACCESS_TOKEN", "GITHUB_REPOSITORY", "GITHUB_SERVER_URL", "HOME", "OPENAI_API_KEY", "PATH"]
- GH_AW_CODEX_SHELL_POLICY_2506e781fc328bb2_EOF
+ GH_AW_CODEX_SHELL_POLICY_5b66884ab5a60c7f_EOF
awk '
BEGIN { skip_openai_proxy = 0 }
/^[[:space:]]*model_provider[[:space:]]*=/ { next }
@@ -861,7 +874,7 @@ jobs:
id: agentic_execution
run: |
set -o pipefail
- printf '%%s' "$(date +%%s%%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
+ printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
mkdir -p "$CODEX_HOME/logs" && touch /tmp/gh-aw/agent-step-summary.md
(umask 177 && touch /tmp/gh-aw/agent-stdio.log)
printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.44/awf-config.schema.json","network":{"allowDomains":["*.githubusercontent.com","172.30.0.1","api.openai.com","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","chatgpt.com","codeload.github.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","docs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","github.blog","github.com","github.githubassets.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.com","lfs.github.com","objects.githubusercontent.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","openai.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","ppa.launchpad.net","raw.githubusercontent.com","s.symcb.com","s.symcd.com","security.ubuntu.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com"]},"apiProxy":{"enabled":true,"maxRuns":100,"maxEffectiveTokens":25000000,"models":{"auto":["large"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"opus":["copilot/*opus*","anthropic/*opus*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"]}},"container":{"imageTag":"0.25.44"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
@@ -1194,6 +1207,10 @@ jobs:
await main();
pre_activation:
+ if: >
+ (!(github.event_name == 'issue_comment') || !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) &&
+ (!(github.event_name == 'issues') || !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)) &&
+ (!(github.event_name == 'pull_request') || !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))
runs-on: ubuntu-slim
permissions:
actions: read
diff --git a/.github/workflows/ai-moderator.md b/.github/workflows/ai-moderator.md
index 0208001bd13..4ca8af6e4f5 100644
--- a/.github/workflows/ai-moderator.md
+++ b/.github/workflows/ai-moderator.md
@@ -11,6 +11,10 @@ on:
pull_request:
types: [opened]
forks: "*"
+ skip-author-associations:
+ issue_comment: [owner, member, collaborator]
+ pull_request: [owner, member, collaborator]
+ issues: [owner, member, collaborator]
skip-roles: [admin, maintainer, write, triage]
skip-bots: [github-actions, copilot, dependabot, renovate, github-copilot-enterprise, copilot-swe-agent]
user-rate-limit:
diff --git a/docs/src/content/docs/guides/maintaining-repos.md b/docs/src/content/docs/guides/maintaining-repos.md
index 4593eab0dbb..d7d41f68bcb 100644
--- a/docs/src/content/docs/guides/maintaining-repos.md
+++ b/docs/src/content/docs/guides/maintaining-repos.md
@@ -104,6 +104,20 @@ user-rate-limit:
See [Rate Limiting Controls](/gh-aw/reference/rate-limiting-controls/) for full options.
+### Pre-Activation Association Skips
+
+For maintainer-operated moderation and triage workflows, you can skip runs early for specific event/author-association combinations using `on.skip-author-associations`:
+
+```aw wrap
+on:
+ issue_comment:
+ types: [created]
+ skip-author-associations:
+ issue_comment: [owner, member, collaborator]
+```
+
+This compiles into a pre-activation job-level `if` guard (using event-specific payload fields such as `github.event.comment.author_association`, `github.event.issue.author_association`, and `github.event.pull_request.author_association`), so matching runs are skipped before agent execution starts.
+
### Concurrency Controls
Workflows automatically use dual concurrency control (per-workflow and per-engine). For repo-assist, you may want higher concurrency so multiple issues are triaged in parallel rather than queued:
diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md
index a897eaf5fa5..fc9203fb92f 100644
--- a/docs/src/content/docs/reference/frontmatter.md
+++ b/docs/src/content/docs/reference/frontmatter.md
@@ -36,6 +36,7 @@ The `on:` section uses standard GitHub Actions syntax to define workflow trigger
- `forks:` - Configure fork filtering for pull_request triggers
- `skip-roles:` - Skip workflow execution for specific repository roles
- `skip-bots:` - Skip workflow execution for specific GitHub actors
+- `skip-author-associations:` - Skip execution for configured event + `author_association` combinations
- `skip-if-match:` - Skip execution when a search query has matches (supports `scope: none`; use top-level `on.github-token` / `on.github-app` for custom auth)
- `skip-if-no-match:` - Skip execution when a search query has no matches (supports `scope: none`; use top-level `on.github-token` / `on.github-app` for custom auth)
- `steps:` - Inject custom deterministic steps into the pre-activation job (saves one workflow job vs. multi-job pattern)
@@ -352,6 +353,28 @@ skip-bots: [github-actions, copilot, renovate]
- Prevent workflow loops where one workflow's output triggers another
- Exempt specific known bots from content checks or policy enforcement
+### Skip Author Associations (`on.skip-author-associations`)
+
+Skip workflow execution at the pre-activation job level when a specific event is triggered by an author with a matching event payload `author_association` field (for example `github.event.comment.author_association`, `github.event.issue.author_association`, or `github.event.pull_request.author_association`).
+
+```yaml wrap
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ skip-author-associations:
+ issue_comment: contributor
+ pull_request_review_comment: [first_time_contributor, none]
+```
+
+**Behavior**:
+
+- Compiles to a job-level `if` expression (no pre-activation script step cost for matched skips)
+- Uses the event-specific payload field (`github.event.comment.author_association`, `github.event.issue.author_association`, or `github.event.pull_request.author_association`)
+- Values are case-insensitive in frontmatter (`contributor` and `CONTRIBUTOR` are treated the same)
+- Supports a single string or an array of strings per event key
+
### Strict Mode (`strict:`)
Enables enhanced security validation for production workflows. **Enabled by default**.
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 77f669a203b..e60dd1bf429 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -1803,6 +1803,28 @@
],
"description": "Skip workflow execution for specific GitHub users. Useful for preventing workflows from running for specific accounts (e.g., bots, specific team members)."
},
+ "skip-author-associations": {
+ "type": "object",
+ "description": "Skip workflow execution when an event-specific payload author_association field (for example: github.event.comment.author_association, github.event.issue.author_association, github.event.pull_request.author_association) matches configured associations for specific events. Keys are event names (for example: issue_comment, pull_request_review_comment, issues, pull_request). Values accept a single string or an array of strings. Association values are case-insensitive in frontmatter.",
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Single author association to skip for this event (e.g., 'contributor' or 'CONTRIBUTOR')."
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "minItems": 1,
+ "description": "List of author associations to skip for this event (case-insensitive)."
+ }
+ ]
+ }
+ },
"roles": {
"description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (\u26a0\ufe0f security consideration).",
"oneOf": [
diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go
index 1011e743778..c4ffa3c92c6 100644
--- a/pkg/workflow/compiler_jobs.go
+++ b/pkg/workflow/compiler_jobs.go
@@ -254,18 +254,19 @@ func (c *Compiler) buildPreActivationAndActivationJobs(data *WorkflowData, front
hasSkipIfNoMatch := data.SkipIfNoMatch != nil
hasSkipRoles := len(data.SkipRoles) > 0
hasSkipBots := len(data.SkipBots) > 0
+ hasSkipAuthorAssociations := len(data.SkipAuthorAssociations) > 0
hasCommandTrigger := len(data.Command) > 0
hasRateLimit := data.RateLimit != nil
hasOnSteps := len(data.OnSteps) > 0
hasOnNeeds := len(data.OnNeeds) > 0
hasLabelNames := len(data.LabelNames) > 0
- compilerJobsLog.Printf("Job configuration: needsPermissionCheck=%v, hasStopTime=%v, hasSkipIfMatch=%v, hasSkipIfNoMatch=%v, hasSkipRoles=%v, hasSkipBots=%v, hasCommand=%v, hasRateLimit=%v, hasOnSteps=%v, hasOnNeeds=%v, hasLabelNames=%v", needsPermissionCheck, hasStopTime, hasSkipIfMatch, hasSkipIfNoMatch, hasSkipRoles, hasSkipBots, hasCommandTrigger, hasRateLimit, hasOnSteps, hasOnNeeds, hasLabelNames)
+ compilerJobsLog.Printf("Job configuration: needsPermissionCheck=%v, hasStopTime=%v, hasSkipIfMatch=%v, hasSkipIfNoMatch=%v, hasSkipRoles=%v, hasSkipBots=%v, hasSkipAuthorAssociations=%v, hasCommand=%v, hasRateLimit=%v, hasOnSteps=%v, hasOnNeeds=%v, hasLabelNames=%v", needsPermissionCheck, hasStopTime, hasSkipIfMatch, hasSkipIfNoMatch, hasSkipRoles, hasSkipBots, hasSkipAuthorAssociations, hasCommandTrigger, hasRateLimit, hasOnSteps, hasOnNeeds, hasLabelNames)
// Build pre-activation job if needed. The job combines:
// - membership checks, stop-time validation, skip-if-match/no-match checks
// - skip-roles/bots checks, rate limit check, command position check
// - on.steps injection, label-names filter
- if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasSkipIfNoMatch || hasSkipRoles || hasSkipBots || hasCommandTrigger || hasRateLimit || hasOnSteps || hasOnNeeds || hasLabelNames {
+ if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasSkipIfNoMatch || hasSkipRoles || hasSkipBots || hasSkipAuthorAssociations || hasCommandTrigger || hasRateLimit || hasOnSteps || hasOnNeeds || hasLabelNames {
compilerJobsLog.Print("Building pre-activation job")
preActivationJob, err := c.buildPreActivationJob(data, needsPermissionCheck)
if err != nil {
diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go
index 3ec3c8d7f6e..666471f2a9f 100644
--- a/pkg/workflow/compiler_orchestrator_workflow.go
+++ b/pkg/workflow/compiler_orchestrator_workflow.go
@@ -355,6 +355,7 @@ func (c *Compiler) extractAdditionalConfigurations(
workflowData.RateLimit = c.extractRateLimitConfig(frontmatter)
workflowData.SkipRoles = c.mergeSkipRoles(c.extractSkipRoles(frontmatter), importsResult.MergedSkipRoles)
workflowData.SkipBots = c.mergeSkipBots(c.extractSkipBots(frontmatter), importsResult.MergedSkipBots)
+ workflowData.SkipAuthorAssociations = c.extractSkipAuthorAssociations(frontmatter)
workflowData.AllowBotAuthoredTriggerComment = c.extractAllowBotAuthoredTriggerComment(frontmatter)
workflowData.ActivationGitHubToken = c.resolveActivationGitHubToken(frontmatter, importsResult)
workflowData.ActivationGitHubApp = c.resolveActivationGitHubApp(frontmatter, importsResult)
diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go
index 21e9ea308ae..d47e3442115 100644
--- a/pkg/workflow/compiler_pre_activation_job.go
+++ b/pkg/workflow/compiler_pre_activation_job.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"maps"
+ "sort"
"strings"
"github.com/github/gh-aw/pkg/constants"
@@ -359,8 +360,11 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
// The activated output is unconditionally true; the user controls
// agent execution through their own if: condition referencing the
// on.steps outputs (e.g., needs.pre_activation.outputs.gate_result).
- if len(data.OnSteps) > 0 || len(data.OnNeeds) > 0 {
- compilerActivationJobsLog.Printf("Pre-activation created with no checks (on.steps=%d, on.needs=%d); activated output is unconditionally true", len(data.OnSteps), len(data.OnNeeds))
+ if len(data.OnSteps) > 0 || len(data.OnNeeds) > 0 || len(data.SkipAuthorAssociations) > 0 {
+ compilerActivationJobsLog.Printf(
+ "Pre-activation created with no output checks (on.steps=%d, on.needs=%d, skip-author-associations=%d); activated output is unconditionally true",
+ len(data.OnSteps), len(data.OnNeeds), len(data.SkipAuthorAssociations),
+ )
activatedNode = BuildStringLiteral("true")
} else {
// This should never happen - it means pre-activation job was created without any checks
@@ -469,6 +473,21 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
}
}
+ // Add optional skip-author-associations event guards as a job-level if condition.
+ // This compiles to a static expression so skipped runs exit early without pre-activation
+ // script execution cost for matching event/association combinations.
+ if len(data.SkipAuthorAssociations) > 0 {
+ skipAuthorAssocCondition := RenderCondition(buildSkipAuthorAssociationsCondition(data.SkipAuthorAssociations))
+ if jobIfCondition != "" {
+ jobIfCondition = RenderCondition(BuildAnd(
+ &ExpressionNode{Expression: skipAuthorAssocCondition},
+ &ExpressionNode{Expression: jobIfCondition},
+ ))
+ } else {
+ jobIfCondition = skipAuthorAssocCondition
+ }
+ }
+
// In script mode, explicitly add a cleanup step (mirrors post.js in dev/release/action mode).
if c.actionMode.IsScript() {
steps = append(steps, c.generateScriptModeCleanupStep())
@@ -592,6 +611,64 @@ func buildCommentAuthorAssociationCondition(bots []string) ConditionNode {
return result
}
+// buildSkipAuthorAssociationsCondition returns a condition that evaluates to true when the
+// workflow should continue, and false when the run should be skipped based on:
+// on.skip-author-associations. containing the event-specific author_association field.
+func buildAuthorAssociationNodeForEvent(eventName string) ConditionNode {
+ switch eventName {
+ case "issue_comment", "pull_request_review_comment", "pull_request_review", "discussion_comment":
+ return BuildPropertyAccess("github.event.comment.author_association")
+ case "issues":
+ return BuildPropertyAccess("github.event.issue.author_association")
+ case "pull_request", "pull_request_target":
+ return BuildPropertyAccess("github.event.pull_request.author_association")
+ default:
+ return &ExpressionNode{Expression: "github.event.comment.author_association || github.event.issue.author_association || github.event.pull_request.author_association || github.event.author_association"}
+ }
+}
+
+func buildSkipAuthorAssociationsCondition(skipAuthorAssociations map[string][]string) ConditionNode {
+ var eventNames []string
+ for eventName, associations := range skipAuthorAssociations {
+ if len(associations) > 0 {
+ eventNames = append(eventNames, eventName)
+ }
+ }
+ sort.Strings(eventNames)
+
+ var skipTerms []ConditionNode
+ for _, eventName := range eventNames {
+ associations := skipAuthorAssociations[eventName]
+ if len(associations) == 0 {
+ continue
+ }
+
+ associationJSON, err := json.Marshal(associations)
+ if err != nil {
+ continue
+ }
+
+ isConfiguredEvent := BuildEquals(
+ BuildPropertyAccess("github.event_name"),
+ BuildStringLiteral(eventName),
+ )
+ associationIsSkipped := BuildFunctionCall(
+ "contains",
+ BuildFunctionCall("fromJSON", BuildStringLiteral(string(associationJSON))),
+ buildAuthorAssociationNodeForEvent(eventName),
+ )
+ skipTerms = append(skipTerms, BuildAnd(isConfiguredEvent, associationIsSkipped))
+ }
+
+ if len(skipTerms) == 0 {
+ return BuildBooleanLiteral(true)
+ }
+
+ // Continue only when no configured (event, author_association) skip condition matched:
+ // NOT(skipTerm1 OR skipTerm2 OR ...).
+ return &NotNode{Child: BuildDisjunction(false, skipTerms...)}
+}
+
// generateReportSkipStep generates the "Report skip reason" step for the pre-activation job.
// The step runs with if: always() and writes skip reasons to the GitHub Actions job summary
// extractPreActivationCustomFields extracts custom steps and outputs from jobs.pre-activation field in frontmatter.
diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go
index 8d19dc11cf5..af271d91aed 100644
--- a/pkg/workflow/compiler_types.go
+++ b/pkg/workflow/compiler_types.go
@@ -478,6 +478,7 @@ type WorkflowData struct {
SkipIfCheckFailing *SkipIfCheckFailingConfig // skip-if-check-failing configuration
SkipRoles []string // roles to skip workflow for (e.g., [admin, maintainer, write])
SkipBots []string // users to skip workflow for (e.g., [user1, user2])
+ SkipAuthorAssociations map[string][]string // author associations to skip by event name (on.skip-author-associations)
AllowBotAuthoredTriggerComment bool // allow bot-posted-menu / user-checks-box pattern (on.allow-bot-authored-trigger-comment)
OnSteps []map[string]any // steps to inject into the pre-activation job from on.steps
OnPermissions *Permissions // additional permissions for the pre-activation job from on.permissions
diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go
index 75be714a9c2..7a0dac3d971 100644
--- a/pkg/workflow/frontmatter_extraction_yaml.go
+++ b/pkg/workflow/frontmatter_extraction_yaml.go
@@ -139,6 +139,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
inSkipIfMatch := false
inSkipIfNoMatch := false
inSkipIfCheckFailing := false
+ inSkipAuthorAssociations := false
inSkipRolesArray := false
inSkipBotsArray := false
inRolesArray := false
@@ -150,13 +151,16 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
currentSection := "" // Track which section we're in ("issues", "pull_request", "discussion", or "issue_comment")
for _, line := range lines {
+ trimmedLine := strings.TrimSpace(line)
+ lineIndent := len(line) - len(strings.TrimLeft(line, " \t"))
+
// Check if we're entering a pull_request, issues, discussion, or issue_comment section.
// Skip these checks when inside on.permissions or on.steps to avoid false matches.
// Example: ` issues: read` inside on.permissions was previously matched as the
// `issues:` event trigger, incorrectly entering the inIssues state and suppressing
// the permission comment-out logic.
- if !inOnPermissions && !inOnSteps {
- if strings.Contains(line, "pull_request:") {
+ if !inOnPermissions && !inOnSteps && !inSkipAuthorAssociations {
+ if lineIndent == 2 && trimmedLine == "pull_request:" {
inPullRequest = true
inIssues = false
inDiscussion = false
@@ -168,7 +172,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
result = append(result, line)
continue
}
- if strings.Contains(line, "issues:") {
+ if lineIndent == 2 && trimmedLine == "issues:" {
inIssues = true
inPullRequest = false
inDiscussion = false
@@ -180,7 +184,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
result = append(result, line)
continue
}
- if strings.Contains(line, "discussion:") {
+ if lineIndent == 2 && trimmedLine == "discussion:" {
inDiscussion = true
inPullRequest = false
inIssues = false
@@ -192,7 +196,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
result = append(result, line)
continue
}
- if strings.Contains(line, "issue_comment:") {
+ if lineIndent == 2 && trimmedLine == "issue_comment:" {
inIssueComment = true
inPullRequest = false
inIssues = false
@@ -204,7 +208,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
result = append(result, line)
continue
}
- if strings.Contains(line, "deployment_status:") {
+ if lineIndent == 2 && trimmedLine == "deployment_status:" {
inDeploymentStatus = true
inWorkflowRun = false
inPullRequest = false
@@ -215,7 +219,7 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
result = append(result, line)
continue
}
- if strings.Contains(line, "workflow_run:") {
+ if lineIndent == 2 && trimmedLine == "workflow_run:" {
inWorkflowRun = true
inDeploymentStatus = false
inPullRequest = false
@@ -252,9 +256,6 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
inWorkflowRunConclusionArray = false
}
- trimmedLine := strings.TrimSpace(line)
- lineIndent := len(line) - len(strings.TrimLeft(line, " \t"))
-
// Skip marker lines in the YAML output
if (inPullRequest || inIssues || inDiscussion || inIssueComment) && strings.Contains(trimmedLine, "__gh_aw_native_label_filter__:") {
// Don't include the marker line in the output
@@ -337,6 +338,13 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
}
}
+ // Check if we're entering skip-author-associations object
+ if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipAuthorAssociations {
+ if strings.HasPrefix(trimmedLine, "skip-author-associations:") && trimmedLine == "skip-author-associations:" {
+ inSkipAuthorAssociations = true
+ }
+ }
+
// Check if we're entering github-app object
if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inGitHubApp {
// Check both uncommented and commented forms
@@ -385,6 +393,16 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
}
}
+ // Check if we're leaving skip-author-associations object (encountering another top-level field)
+ if inSkipAuthorAssociations && strings.TrimSpace(line) != "" &&
+ !strings.HasPrefix(trimmedLine, "skip-author-associations:") &&
+ !strings.HasPrefix(trimmedLine, "# skip-author-associations:") {
+ currentIndent := len(line) - len(strings.TrimLeft(line, " \t"))
+ if currentIndent == 2 && !strings.HasPrefix(trimmedLine, "#") {
+ inSkipAuthorAssociations = false
+ }
+ }
+
// Check if we're leaving github-app object (encountering another top-level field)
// Skip this check if we just entered github-app on this line
if inGitHubApp && strings.TrimSpace(line) != "" &&
@@ -516,6 +534,12 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat
// Comment out nested fields and list items in skip-if-check-failing object
shouldComment = true
commentReason = ""
+ } else if strings.HasPrefix(trimmedLine, "skip-author-associations:") {
+ shouldComment = true
+ commentReason = " # Skip-author-associations compiled into pre-activation job if condition"
+ } else if inSkipAuthorAssociations && lineIndent > 2 {
+ shouldComment = true
+ commentReason = ""
} else if strings.HasPrefix(trimmedLine, "skip-roles:") {
shouldComment = true
commentReason = " # Skip-roles processed as role check in pre-activation job"
diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go
index 8d2e42cdf3d..2af2aa900b2 100644
--- a/pkg/workflow/role_checks.go
+++ b/pkg/workflow/role_checks.go
@@ -568,6 +568,54 @@ func (c *Compiler) extractSkipBots(frontmatter map[string]any) []string {
return extractSkipField(frontmatter, "skip-bots")
}
+// extractSkipAuthorAssociations extracts the 'skip-author-associations' field from the 'on:' section.
+// The field is an object keyed by event name with values as a string or string array.
+func (c *Compiler) extractSkipAuthorAssociations(frontmatter map[string]any) map[string][]string {
+ onValue, exists := frontmatter["on"]
+ if !exists || onValue == nil {
+ return nil
+ }
+
+ onMap, ok := onValue.(map[string]any)
+ if !ok {
+ return nil
+ }
+
+ rawValue, exists := onMap["skip-author-associations"]
+ if !exists || rawValue == nil {
+ return nil
+ }
+
+ rawMap, ok := rawValue.(map[string]any)
+ if !ok {
+ return nil
+ }
+
+ result := make(map[string][]string)
+ for eventName, associationsValue := range rawMap {
+ associations := parseOptionalStringSliceField(associationsValue, "on.skip-author-associations."+eventName)
+ if len(associations) == 0 {
+ continue
+ }
+ normalizedAssociations := make([]string, 0, len(associations))
+ for _, association := range associations {
+ normalized := strings.ToUpper(strings.TrimSpace(association))
+ if normalized != "" {
+ normalizedAssociations = append(normalizedAssociations, normalized)
+ }
+ }
+ if len(normalizedAssociations) == 0 {
+ continue
+ }
+ result[eventName] = sliceutil.Deduplicate(normalizedAssociations)
+ }
+
+ if len(result) == 0 {
+ return nil
+ }
+ return result
+}
+
// extractAllowBotAuthoredTriggerComment extracts the 'allow-bot-authored-trigger-comment' boolean
// from the 'on:' section of frontmatter. When true, the confused-deputy mismatch check is skipped
// for issue_comment:edited events where the comment was authored by a bot — the bot-posted-menu /
diff --git a/pkg/workflow/skip_author_associations_test.go b/pkg/workflow/skip_author_associations_test.go
new file mode 100644
index 00000000000..2f3e0a1dafd
--- /dev/null
+++ b/pkg/workflow/skip_author_associations_test.go
@@ -0,0 +1,109 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/github/gh-aw/pkg/stringutil"
+ "github.com/github/gh-aw/pkg/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func TestExtractSkipAuthorAssociations(t *testing.T) {
+ compiler := NewCompiler()
+
+ frontmatter := map[string]any{
+ "on": map[string]any{
+ "skip-author-associations": map[string]any{
+ "issue_comment": "contributor",
+ "pull_request_review_comment": []any{"OWNER", "member", "owner", ""},
+ "discussion_comment": []string{"first_timer"},
+ "pull_request_review": "",
+ "pull_request_review_threaded": []any{},
+ },
+ },
+ }
+
+ got := compiler.extractSkipAuthorAssociations(frontmatter)
+ want := map[string][]string{
+ "issue_comment": {"CONTRIBUTOR"},
+ "pull_request_review_comment": {"OWNER", "MEMBER"},
+ "discussion_comment": {"FIRST_TIMER"},
+ }
+ assert.Equal(t, want, got)
+}
+
+func TestSkipAuthorAssociationsCompilesToPreActivationIf(t *testing.T) {
+ tmpDir := testutil.TempDir(t, "skip-author-associations-test")
+ compiler := NewCompiler()
+
+ workflowContent := `---
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened]
+ pull_request:
+ types: [opened]
+ roles: all
+ skip-author-associations:
+ issue_comment: contributor
+ pull_request_review_comment: [first_time_contributor, none]
+ issues: owner
+ pull_request: member
+engine: copilot
+---
+
+# Skip Author Associations Workflow
+`
+
+ workflowFile := filepath.Join(tmpDir, "skip-author-associations.md")
+ err := os.WriteFile(workflowFile, []byte(workflowContent), 0644)
+ require.NoError(t, err)
+
+ err = compiler.CompileWorkflow(workflowFile)
+ require.NoError(t, err)
+
+ lockFile := stringutil.MarkdownToLockFile(workflowFile)
+ lockContent, err := os.ReadFile(lockFile)
+ require.NoError(t, err)
+
+ lockContentStr := string(lockContent)
+ preActivationSection := extractJobSection(lockContentStr, "pre_activation")
+ require.NotEmpty(t, preActivationSection)
+
+ assert.Contains(t, preActivationSection, "github.event.comment.author_association")
+ assert.Contains(t, preActivationSection, "github.event.issue.author_association")
+ assert.Contains(t, preActivationSection, "github.event.pull_request.author_association")
+ assert.Contains(t, preActivationSection, "github.event_name == 'issue_comment'")
+ assert.Contains(t, preActivationSection, "github.event_name == 'pull_request_review_comment'")
+ assert.Contains(t, preActivationSection, "github.event_name == 'issues'")
+ assert.Contains(t, preActivationSection, "github.event_name == 'pull_request'")
+ assert.Contains(t, preActivationSection, "CONTRIBUTOR")
+ assert.Contains(t, preActivationSection, "FIRST_TIME_CONTRIBUTOR")
+ assert.Contains(t, preActivationSection, "NONE")
+ assert.Contains(t, preActivationSection, "OWNER")
+ assert.Contains(t, preActivationSection, "MEMBER")
+ assert.Contains(t, preActivationSection, "!(")
+ assert.Contains(t, preActivationSection, "||")
+ assert.Contains(t, preActivationSection, "&&")
+
+ assert.Contains(t, lockContentStr, "# skip-author-associations:")
+ assert.Contains(t, lockContentStr, " # issue_comment: contributor")
+ assert.Contains(t, lockContentStr, " # pull_request_review_comment:")
+ assert.Contains(t, lockContentStr, " # issues: owner")
+ assert.Contains(t, lockContentStr, " # pull_request: member")
+ assert.Contains(t, lockContentStr, " # - first_time_contributor")
+ assert.Contains(t, lockContentStr, " # - none")
+ assert.NotContains(t, lockContentStr, "skip-author-association:")
+
+ var workflow map[string]any
+ require.NoError(t, yaml.Unmarshal(lockContent, &workflow), "compiled lock file should be valid YAML")
+}