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") +}