From be14d2260008e39008944f79c36a5545dc1e93ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:24:40 +0000 Subject: [PATCH 1/4] Initial plan From bbaaf144be936a1ce541f1d70613d0e35c7b5334 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:49:18 +0000 Subject: [PATCH 2/4] Add concurrency.job-discriminator to fix fan-out cancellations in job-level concurrency groups Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 5 + pkg/workflow/compiler.go | 7 + .../compiler_orchestrator_workflow.go | 60 ++++++- .../compiler_orchestrator_workflow_test.go | 128 ++++++++++++++ pkg/workflow/compiler_types.go | 163 +++++++++--------- pkg/workflow/concurrency.go | 6 + pkg/workflow/concurrency_test.go | 46 +++++ 7 files changed, 333 insertions(+), 82 deletions(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index edd132fc930..6c22bdbdbf6 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1956,6 +1956,11 @@ "cancel-in-progress": { "type": "boolean", "description": "Whether to cancel in-progress workflows in the same concurrency group when a new one starts. Default: false (queue new runs). Set to true for agentic workflows where only the latest run matters (e.g., PR analysis that becomes stale when new commits are pushed)." + }, + "job-discriminator": { + "type": "string", + "description": "Additional discriminator expression appended to compiler-generated job-level concurrency groups (agent, output jobs). Use this when multiple workflow instances are dispatched concurrently with different inputs (fan-out pattern) to prevent job-level concurrency groups from colliding. For example, '${{ inputs.finding_id }}' ensures each dispatched run gets a unique job-level group. Supports GitHub Actions expressions. This field is stripped from the compiled lock file (it is a gh-aw extension, not a GitHub Actions field).", + "examples": ["${{ inputs.finding_id }}", "${{ inputs.item_id }}", "${{ github.run_id }}"] } }, "required": ["group"], diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 2b21976cdcd..c89cffc80bb 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -188,6 +188,13 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath } } + // Validate concurrency.job-discriminator expression + if workflowData.ConcurrencyJobDiscriminator != "" { + if err := validateConcurrencyGroupExpression(workflowData.ConcurrencyJobDiscriminator); err != nil { + return formatCompilerError(markdownPath, "error", "concurrency.job-discriminator validation failed: "+err.Error(), err) + } + } + // Validate engine-level concurrency group expression log.Printf("Validating engine-level concurrency configuration") if workflowData.EngineConfig != nil && workflowData.EngineConfig.Concurrency != "" { diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 3612b9e8890..a96365a255c 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -224,7 +224,8 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData workflowData.HasDispatchItemNumber = extractDispatchItemNumber(frontmatter) workflowData.Permissions = c.extractPermissions(frontmatter) workflowData.Network = c.extractTopLevelYAMLSection(frontmatter, "network") - workflowData.Concurrency = c.extractTopLevelYAMLSection(frontmatter, "concurrency") + workflowData.ConcurrencyJobDiscriminator = extractConcurrencyJobDiscriminator(frontmatter) + workflowData.Concurrency = c.extractConcurrencySection(frontmatter) workflowData.RunName = c.extractTopLevelYAMLSection(frontmatter, "run-name") workflowData.Env = c.extractTopLevelYAMLSection(frontmatter, "env") workflowData.Features = c.extractFeatures(frontmatter) @@ -239,6 +240,63 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData workflowData.Cache = c.extractTopLevelYAMLSection(frontmatter, "cache") } +// extractConcurrencyJobDiscriminator reads the job-discriminator value from the +// frontmatter concurrency block without modifying the original map. +// Returns the discriminator expression string or empty string if not present. +func extractConcurrencyJobDiscriminator(frontmatter map[string]any) string { + concurrencyRaw, ok := frontmatter["concurrency"] + if !ok { + return "" + } + concurrencyMap, ok := concurrencyRaw.(map[string]any) + if !ok { + return "" + } + discriminator, ok := concurrencyMap["job-discriminator"] + if !ok { + return "" + } + discriminatorStr, ok := discriminator.(string) + if !ok { + return "" + } + return discriminatorStr +} + +// extractConcurrencySection extracts the workflow-level concurrency YAML section, +// stripping the gh-aw-specific job-discriminator field so it does not appear in +// the compiled lock file (which must be valid GitHub Actions YAML). +func (c *Compiler) extractConcurrencySection(frontmatter map[string]any) string { + concurrencyRaw, ok := frontmatter["concurrency"] + if !ok { + return "" + } + concurrencyMap, ok := concurrencyRaw.(map[string]any) + if !ok || len(concurrencyMap) == 0 { + // String or empty format: serialize as-is (no job-discriminator possible) + return c.extractTopLevelYAMLSection(frontmatter, "concurrency") + } + + _, hasDiscriminator := concurrencyMap["job-discriminator"] + if !hasDiscriminator { + return c.extractTopLevelYAMLSection(frontmatter, "concurrency") + } + + // Build a copy of the concurrency map without job-discriminator for serialization. + // Use len(concurrencyMap) for capacity: at most one entry (job-discriminator) will be + // omitted, so this is a slight over-allocation that avoids a subtle negative-capacity + // edge case if job-discriminator were the only key. + cleanMap := make(map[string]any, len(concurrencyMap)) + for k, v := range concurrencyMap { + if k != "job-discriminator" { + cleanMap[k] = v + } + } + // Use a minimal temporary frontmatter containing only the concurrency key to avoid + // copying the entire (potentially large) frontmatter map. + return c.extractTopLevelYAMLSection(map[string]any{"concurrency": cleanMap}, "concurrency") +} + // extractDispatchItemNumber reports whether the frontmatter's on.workflow_dispatch // trigger exposes an item_number input. This is the signature produced by the label // trigger shorthand (e.g. "on: pull_request labeled my-label"). Reading the diff --git a/pkg/workflow/compiler_orchestrator_workflow_test.go b/pkg/workflow/compiler_orchestrator_workflow_test.go index 98bb688389b..f9653fec42c 100644 --- a/pkg/workflow/compiler_orchestrator_workflow_test.go +++ b/pkg/workflow/compiler_orchestrator_workflow_test.go @@ -1575,3 +1575,131 @@ func TestExtractYAMLSections_HasDispatchItemNumber(t *testing.T) { assert.False(t, workflowData.HasDispatchItemNumber, "should not set HasDispatchItemNumber for plain workflow") }) } + +// TestExtractConcurrencyJobDiscriminator tests extraction of job-discriminator from the concurrency block +func TestExtractConcurrencyJobDiscriminator(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + want string + }{ + { + name: "job-discriminator present in concurrency object", + frontmatter: map[string]any{ + "concurrency": map[string]any{ + "group": "gh-aw-${{ github.workflow }}-${{ inputs.finding_id }}", + "job-discriminator": "${{ inputs.finding_id }}", + }, + }, + want: "${{ inputs.finding_id }}", + }, + { + name: "concurrency object without job-discriminator", + frontmatter: map[string]any{ + "concurrency": map[string]any{ + "group": "gh-aw-${{ github.workflow }}", + "cancel-in-progress": false, + }, + }, + want: "", + }, + { + name: "concurrency as string (no job-discriminator)", + frontmatter: map[string]any{ + "concurrency": "gh-aw-${{ github.workflow }}", + }, + want: "", + }, + { + name: "no concurrency key", + frontmatter: map[string]any{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractConcurrencyJobDiscriminator(tt.frontmatter) + assert.Equal(t, tt.want, got, "extractConcurrencyJobDiscriminator() mismatch") + }) + } +} + +// TestExtractConcurrencySection tests that job-discriminator is stripped from the serialized YAML +func TestExtractConcurrencySection(t *testing.T) { + compiler := NewCompiler() + + t.Run("job-discriminator is stripped from serialized YAML", func(t *testing.T) { + frontmatter := map[string]any{ + "concurrency": map[string]any{ + "group": "gh-aw-${{ github.workflow }}-${{ inputs.finding_id }}", + "job-discriminator": "${{ inputs.finding_id }}", + }, + } + result := compiler.extractConcurrencySection(frontmatter) + assert.NotContains(t, result, "job-discriminator", "job-discriminator should be stripped from serialized concurrency YAML") + assert.Contains(t, result, "group:", "group field should remain in serialized YAML") + assert.Contains(t, result, "gh-aw-${{ github.workflow }}-${{ inputs.finding_id }}", "group value should be preserved") + }) + + t.Run("original frontmatter is not modified", func(t *testing.T) { + frontmatter := map[string]any{ + "concurrency": map[string]any{ + "group": "gh-aw-${{ github.workflow }}-${{ inputs.finding_id }}", + "job-discriminator": "${{ inputs.finding_id }}", + }, + } + compiler.extractConcurrencySection(frontmatter) + // Original frontmatter should still have job-discriminator + concurrencyMap := frontmatter["concurrency"].(map[string]any) + _, hasDiscriminator := concurrencyMap["job-discriminator"] + assert.True(t, hasDiscriminator, "original frontmatter should not be modified") + }) + + t.Run("concurrency without job-discriminator passes through unchanged", func(t *testing.T) { + frontmatter := map[string]any{ + "concurrency": map[string]any{ + "group": "gh-aw-${{ github.workflow }}", + "cancel-in-progress": false, + }, + } + result := compiler.extractConcurrencySection(frontmatter) + assert.Contains(t, result, "group:", "group field should be present") + assert.NotContains(t, result, "job-discriminator", "no job-discriminator should appear") + }) +} + +// TestExtractYAMLSections_ConcurrencyJobDiscriminator verifies that extractYAMLSections +// populates WorkflowData.ConcurrencyJobDiscriminator from the frontmatter. +func TestExtractYAMLSections_ConcurrencyJobDiscriminator(t *testing.T) { + compiler := NewCompiler() + + t.Run("job-discriminator is extracted and stored", func(t *testing.T) { + workflowData := &WorkflowData{} + frontmatter := map[string]any{ + "on": map[string]any{"workflow_dispatch": nil}, + "concurrency": map[string]any{ + "group": "gh-aw-${{ github.workflow }}-${{ inputs.finding_id }}", + "job-discriminator": "${{ inputs.finding_id }}", + }, + } + compiler.extractYAMLSections(frontmatter, workflowData) + assert.Equal(t, "${{ inputs.finding_id }}", workflowData.ConcurrencyJobDiscriminator, + "ConcurrencyJobDiscriminator should be populated from frontmatter") + assert.NotContains(t, workflowData.Concurrency, "job-discriminator", + "Concurrency YAML should not include job-discriminator") + }) + + t.Run("no job-discriminator leaves field empty", func(t *testing.T) { + workflowData := &WorkflowData{} + frontmatter := map[string]any{ + "on": map[string]any{"workflow_dispatch": nil}, + "concurrency": map[string]any{ + "group": "gh-aw-${{ github.workflow }}", + }, + } + compiler.extractYAMLSections(frontmatter, workflowData) + assert.Empty(t, workflowData.ConcurrencyJobDiscriminator, + "ConcurrencyJobDiscriminator should be empty when not in frontmatter") + }) +} diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 44c353d7e9b..e38b376148c 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -351,87 +351,88 @@ type SkipIfNoMatchConfig struct { // WorkflowData holds all the data needed to generate a GitHub Actions workflow type WorkflowData struct { - Name string - WorkflowID string // workflow identifier derived from markdown filename (basename without extension) - TrialMode bool // whether the workflow is running in trial mode - TrialLogicalRepo string // target repository slug for trial mode (owner/repo) - FrontmatterName string // name field from frontmatter (for code scanning alert driver default) - FrontmatterYAML string // raw frontmatter YAML content (rendered as comment in lock file for reference) - Description string // optional description rendered as comment in lock file - Source string // optional source field (owner/repo@ref/path) rendered as comment in lock file - TrackerID string // optional tracker identifier for created assets (min 8 chars, alphanumeric + hyphens/underscores) - ImportedFiles []string // list of files imported via imports field (rendered as comment in lock file) - ImportedMarkdown string // Only imports WITH inputs (for compile-time substitution) - ImportPaths []string // Import file paths for runtime-import macro generation (imports without inputs) - MainWorkflowMarkdown string // main workflow markdown without imports (for runtime-import) - IncludedFiles []string // list of files included via @include directives (rendered as comment in lock file) - ImportInputs map[string]any // input values from imports with inputs (for github.aw.inputs.* substitution) - On string - Permissions string - Network string // top-level network permissions configuration - Concurrency string // workflow-level concurrency configuration - RunName string - Env string - If string - TimeoutMinutes string - CustomSteps string - PostSteps string // steps to run after AI execution - RunsOn string - Environment string // environment setting for the main job - Container string // container setting for the main job - Services string // services setting for the main job - Tools map[string]any - ParsedTools *Tools // Structured tools configuration (NEW: parsed from Tools map) - MarkdownContent string - AI string // "claude" or "codex" (for backwards compatibility) - EngineConfig *EngineConfig // Extended engine configuration - AgentFile string // Path to custom agent file (from imports) - AgentImportSpec string // Original import specification for agent file (e.g., "owner/repo/path@ref") - RepositoryImports []string // Repository-only imports (format: "owner/repo@ref") for .github folder merging - StopTime string - SkipIfMatch *SkipIfMatchConfig // skip-if-match configuration with query and max threshold - SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold - SkipRoles []string // roles to skip workflow for (e.g., [admin, maintainer, write]) - SkipBots []string // users to skip workflow for (e.g., [user1, user2]) - ManualApproval string // environment name for manual approval from on: section - Command []string // for /command trigger support - multiple command names - CommandEvents []string // events where command should be active (nil = all events) - CommandOtherEvents map[string]any // for merging command with other events - AIReaction string // AI reaction type like "eyes", "heart", etc. - StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) - ActivationGitHubToken string // custom github token from on.github-token for reactions/comments - ActivationGitHubApp *GitHubAppConfig // github app config from on.github-app for minting activation tokens - LockForAgent bool // whether to lock the issue during agent workflow execution - Jobs map[string]any // custom job configurations with dependencies - Cache string // cache configuration - NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} - NetworkPermissions *NetworkPermissions // parsed network permissions - SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT) - SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes - MCPScripts *MCPScriptsConfig // mcp-scripts 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 - PluginInfo *PluginInfo // Consolidated plugin information (plugins, custom token, MCP configs) - APMDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependency packages to install - ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default) - ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default) - Features map[string]any // feature flags and configuration options from frontmatter (supports bool and string values) - ActionCache *ActionCache // cache for action pin resolutions - ActionResolver *ActionResolver // resolver for action pins - StrictMode bool // strict mode for action pinning - SecretMasking *SecretMaskingConfig // secret masking configuration - ParsedFrontmatter *FrontmatterConfig // cached parsed frontmatter configuration (for performance optimization) - RawFrontmatter map[string]any // raw parsed frontmatter map (for passing to hash functions without re-parsing) - ActionPinWarnings map[string]bool // cache of already-warned action pin failures (key: "repo@version") - ActionMode ActionMode // action mode for workflow compilation (dev, release, script) - HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter - InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) - CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter - HasDispatchItemNumber bool // true when workflow_dispatch has item_number input (generated by label trigger shorthand) + Name string + WorkflowID string // workflow identifier derived from markdown filename (basename without extension) + TrialMode bool // whether the workflow is running in trial mode + TrialLogicalRepo string // target repository slug for trial mode (owner/repo) + FrontmatterName string // name field from frontmatter (for code scanning alert driver default) + FrontmatterYAML string // raw frontmatter YAML content (rendered as comment in lock file for reference) + Description string // optional description rendered as comment in lock file + Source string // optional source field (owner/repo@ref/path) rendered as comment in lock file + TrackerID string // optional tracker identifier for created assets (min 8 chars, alphanumeric + hyphens/underscores) + ImportedFiles []string // list of files imported via imports field (rendered as comment in lock file) + ImportedMarkdown string // Only imports WITH inputs (for compile-time substitution) + ImportPaths []string // Import file paths for runtime-import macro generation (imports without inputs) + MainWorkflowMarkdown string // main workflow markdown without imports (for runtime-import) + IncludedFiles []string // list of files included via @include directives (rendered as comment in lock file) + ImportInputs map[string]any // input values from imports with inputs (for github.aw.inputs.* substitution) + On string + Permissions string + Network string // top-level network permissions configuration + Concurrency string // workflow-level concurrency configuration + RunName string + Env string + If string + TimeoutMinutes string + CustomSteps string + PostSteps string // steps to run after AI execution + RunsOn string + Environment string // environment setting for the main job + Container string // container setting for the main job + Services string // services setting for the main job + Tools map[string]any + ParsedTools *Tools // Structured tools configuration (NEW: parsed from Tools map) + MarkdownContent string + AI string // "claude" or "codex" (for backwards compatibility) + EngineConfig *EngineConfig // Extended engine configuration + AgentFile string // Path to custom agent file (from imports) + AgentImportSpec string // Original import specification for agent file (e.g., "owner/repo/path@ref") + RepositoryImports []string // Repository-only imports (format: "owner/repo@ref") for .github folder merging + StopTime string + SkipIfMatch *SkipIfMatchConfig // skip-if-match configuration with query and max threshold + SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold + SkipRoles []string // roles to skip workflow for (e.g., [admin, maintainer, write]) + SkipBots []string // users to skip workflow for (e.g., [user1, user2]) + ManualApproval string // environment name for manual approval from on: section + Command []string // for /command trigger support - multiple command names + CommandEvents []string // events where command should be active (nil = all events) + CommandOtherEvents map[string]any // for merging command with other events + AIReaction string // AI reaction type like "eyes", "heart", etc. + StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) + ActivationGitHubToken string // custom github token from on.github-token for reactions/comments + ActivationGitHubApp *GitHubAppConfig // github app config from on.github-app for minting activation tokens + LockForAgent bool // whether to lock the issue during agent workflow execution + Jobs map[string]any // custom job configurations with dependencies + Cache string // cache configuration + NeedsTextOutput bool // whether the workflow uses ${{ needs.task.outputs.text }} + NetworkPermissions *NetworkPermissions // parsed network permissions + SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT) + SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes + MCPScripts *MCPScriptsConfig // mcp-scripts 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 + PluginInfo *PluginInfo // Consolidated plugin information (plugins, custom token, MCP configs) + APMDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependency packages to install + ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default) + ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default) + Features map[string]any // feature flags and configuration options from frontmatter (supports bool and string values) + ActionCache *ActionCache // cache for action pin resolutions + ActionResolver *ActionResolver // resolver for action pins + StrictMode bool // strict mode for action pinning + SecretMasking *SecretMaskingConfig // secret masking configuration + ParsedFrontmatter *FrontmatterConfig // cached parsed frontmatter configuration (for performance optimization) + RawFrontmatter map[string]any // raw parsed frontmatter map (for passing to hash functions without re-parsing) + ActionPinWarnings map[string]bool // cache of already-warned action pin failures (key: "repo@version") + ActionMode ActionMode // action mode for workflow compilation (dev, release, script) + HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter + InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) + CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter + HasDispatchItemNumber bool // true when workflow_dispatch has item_number input (generated by label trigger shorthand) + ConcurrencyJobDiscriminator string // optional discriminator expression appended to job-level concurrency groups (from concurrency.job-discriminator) } // BaseSafeOutputConfig holds common configuration fields for all safe output types diff --git a/pkg/workflow/concurrency.go b/pkg/workflow/concurrency.go index bad46626e77..e0e273668da 100644 --- a/pkg/workflow/concurrency.go +++ b/pkg/workflow/concurrency.go @@ -69,6 +69,12 @@ func GenerateJobConcurrencyConfig(workflowData *WorkflowData) string { // Build the default concurrency configuration groupValue := fmt.Sprintf("gh-aw-%s-${{ github.workflow }}", engineID) + // If the user specified a job-discriminator, append it so that concurrent + // runs with different inputs (fan-out pattern) do not share the same group. + if workflowData.ConcurrencyJobDiscriminator != "" { + concurrencyLog.Printf("Appending job discriminator to job-level concurrency group: %s", workflowData.ConcurrencyJobDiscriminator) + groupValue = fmt.Sprintf("%s-%s", groupValue, workflowData.ConcurrencyJobDiscriminator) + } concurrencyConfig := fmt.Sprintf("concurrency:\n group: \"%s\"", groupValue) return concurrencyConfig diff --git a/pkg/workflow/concurrency_test.go b/pkg/workflow/concurrency_test.go index fc034cac000..0ca02cc0bc4 100644 --- a/pkg/workflow/concurrency_test.go +++ b/pkg/workflow/concurrency_test.go @@ -474,6 +474,52 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) { expected: "", description: "Rendered slash_command YAML (issue_comment + workflow_dispatch) should NOT get default concurrency (isIssueWorkflow detects it)", }, + { + name: "Job discriminator appended to default group for schedule workflow", + workflowData: &WorkflowData{ + On: "on:\n schedule:\n - cron: '0 0 * * *'", + EngineConfig: &EngineConfig{ID: "copilot"}, + ConcurrencyJobDiscriminator: "${{ inputs.finding_id }}", + }, + expected: `concurrency: + group: "gh-aw-copilot-${{ github.workflow }}-${{ inputs.finding_id }}"`, + description: "job-discriminator should be appended to the default group to prevent fan-out cancellations", + }, + { + name: "Job discriminator appended when workflow_dispatch combined with schedule", + workflowData: &WorkflowData{ + On: "on:\n workflow_dispatch:\n schedule:\n - cron: '0 0 * * *'", + EngineConfig: &EngineConfig{ID: "copilot"}, + ConcurrencyJobDiscriminator: "${{ inputs.item_id }}", + }, + expected: `concurrency: + group: "gh-aw-copilot-${{ github.workflow }}-${{ inputs.item_id }}"`, + description: "job-discriminator should be appended for mixed workflow_dispatch + schedule workflows", + }, + { + name: "Job discriminator ignored when workflow has special triggers (no default group generated)", + workflowData: &WorkflowData{ + On: "on:\n workflow_dispatch:", + EngineConfig: &EngineConfig{ID: "copilot"}, + ConcurrencyJobDiscriminator: "${{ inputs.finding_id }}", + }, + expected: "", + description: "job-discriminator has no effect when no default job concurrency is generated (workflow_dispatch-only)", + }, + { + name: "Job discriminator ignored when engine provides explicit concurrency", + workflowData: &WorkflowData{ + On: "on:\n schedule:\n - cron: '0 0 * * *'", + EngineConfig: &EngineConfig{ + ID: "copilot", + Concurrency: "concurrency:\n group: \"engine-custom-group\"", + }, + ConcurrencyJobDiscriminator: "${{ inputs.finding_id }}", + }, + expected: `concurrency: + group: "engine-custom-group"`, + description: "job-discriminator does not modify explicitly set engine concurrency", + }, } for _, tt := range tests { From 4d82b6b9a7775632f3b22030e0657b85546b3b0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:59:37 +0000 Subject: [PATCH 3/4] Make group field non-required in concurrency schema, allow job-discriminator standalone Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 5 ++++- pkg/workflow/compiler_orchestrator_workflow.go | 5 +++++ pkg/workflow/compiler_orchestrator_workflow_test.go | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 6c22bdbdbf6..67a512f6e14 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1963,11 +1963,14 @@ "examples": ["${{ inputs.finding_id }}", "${{ inputs.item_id }}", "${{ github.run_id }}"] } }, - "required": ["group"], + "required": [], "examples": [ { "group": "dev-workflow-${{ github.ref }}", "cancel-in-progress": true + }, + { + "job-discriminator": "${{ inputs.finding_id }}" } ] } diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index a96365a255c..44da10577a5 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -292,6 +292,11 @@ func (c *Compiler) extractConcurrencySection(frontmatter map[string]any) string cleanMap[k] = v } } + // When job-discriminator is the only field, there is no user-specified workflow-level + // group to emit; return empty so the compiler can generate the default concurrency. + if len(cleanMap) == 0 { + return "" + } // Use a minimal temporary frontmatter containing only the concurrency key to avoid // copying the entire (potentially large) frontmatter map. return c.extractTopLevelYAMLSection(map[string]any{"concurrency": cleanMap}, "concurrency") diff --git a/pkg/workflow/compiler_orchestrator_workflow_test.go b/pkg/workflow/compiler_orchestrator_workflow_test.go index f9653fec42c..439db5aa8a3 100644 --- a/pkg/workflow/compiler_orchestrator_workflow_test.go +++ b/pkg/workflow/compiler_orchestrator_workflow_test.go @@ -1667,6 +1667,16 @@ func TestExtractConcurrencySection(t *testing.T) { assert.Contains(t, result, "group:", "group field should be present") assert.NotContains(t, result, "job-discriminator", "no job-discriminator should appear") }) + + t.Run("job-discriminator only (no group) returns empty string", func(t *testing.T) { + frontmatter := map[string]any{ + "concurrency": map[string]any{ + "job-discriminator": "${{ inputs.finding_id }}", + }, + } + result := compiler.extractConcurrencySection(frontmatter) + assert.Empty(t, result, "when only job-discriminator is present the workflow-level concurrency should be empty (compiler generates defaults)") + }) } // TestExtractYAMLSections_ConcurrencyJobDiscriminator verifies that extractYAMLSections From 7de7af007031217d1424fb86f7a00eb02d258b3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:15:31 +0000 Subject: [PATCH 4/4] Add more test cases for job-discriminator; apply to stale-repo-identifier and slide-deck-maintainer workflows Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/slide-deck-maintainer.lock.yml | 4 +- .github/workflows/slide-deck-maintainer.md | 2 + .../workflows/stale-repo-identifier.lock.yml | 4 +- .github/workflows/stale-repo-identifier.md | 3 ++ pkg/workflow/concurrency_test.go | 42 +++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/workflows/slide-deck-maintainer.lock.yml b/.github/workflows/slide-deck-maintainer.lock.yml index 45c0f512ee1..69633c5e51d 100644 --- a/.github/workflows/slide-deck-maintainer.lock.yml +++ b/.github/workflows/slide-deck-maintainer.lock.yml @@ -23,7 +23,7 @@ # # Maintains the gh-aw slide deck by scanning repository content and detecting layout issues using Playwright # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"0b3d7f1cb6dbc12d69cb6f2f524b6c7eaec295bbc300df932437af7677e97e6c","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"52aa9f9c22b63de50f98ab9bb986ccbfaf180e62d4cf90d4c55c5b235fe07769","strict":true} name: "Slide Deck Maintainer" "on": @@ -269,7 +269,7 @@ jobs: issues: read pull-requests: read concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" + group: "gh-aw-copilot-${{ github.workflow }}-${{ inputs.focus || github.run_id }}" env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_AW_ASSETS_ALLOWED_EXTS: "" diff --git a/.github/workflows/slide-deck-maintainer.md b/.github/workflows/slide-deck-maintainer.md index bb8c3e69212..51af960c278 100644 --- a/.github/workflows/slide-deck-maintainer.md +++ b/.github/workflows/slide-deck-maintainer.md @@ -15,6 +15,8 @@ permissions: contents: read pull-requests: read issues: read +concurrency: + job-discriminator: ${{ inputs.focus || github.run_id }} tracker-id: slide-deck-maintainer engine: copilot timeout-minutes: 45 diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 5bf556974fe..e544ad3c263 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -29,7 +29,7 @@ # - shared/python-dataviz.md # - shared/trending-charts-simple.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"cb88eff6090a5e966484e7a4dd6f39dcd1b246e1547c910ce2695d7faf605d00","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"cb8216252ac0aead193e61a5e172c70364c2d476bc496fc28e876f124903323c","strict":true} name: "Stale Repository Identifier" "on": @@ -280,7 +280,7 @@ jobs: issues: read pull-requests: read concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" + group: "gh-aw-copilot-${{ github.workflow }}-${{ inputs.organization || github.run_id }}" env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_AW_ASSETS_ALLOWED_EXTS: ".png,.jpg,.jpeg" diff --git a/.github/workflows/stale-repo-identifier.md b/.github/workflows/stale-repo-identifier.md index 3261daf0a72..f9cc3e9ac34 100644 --- a/.github/workflows/stale-repo-identifier.md +++ b/.github/workflows/stale-repo-identifier.md @@ -17,6 +17,9 @@ permissions: pull-requests: read actions: read +concurrency: + job-discriminator: ${{ inputs.organization || github.run_id }} + engine: copilot strict: true timeout-minutes: 45 diff --git a/pkg/workflow/concurrency_test.go b/pkg/workflow/concurrency_test.go index 0ca02cc0bc4..3e949b77f3e 100644 --- a/pkg/workflow/concurrency_test.go +++ b/pkg/workflow/concurrency_test.go @@ -520,6 +520,48 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) { group: "engine-custom-group"`, description: "job-discriminator does not modify explicitly set engine concurrency", }, + { + name: "Job discriminator using github.run_id for universal uniqueness (schedule)", + workflowData: &WorkflowData{ + On: "on:\n schedule:\n - cron: '0 0 * * *'", + EngineConfig: &EngineConfig{ID: "copilot"}, + ConcurrencyJobDiscriminator: "${{ github.run_id }}", + }, + expected: `concurrency: + group: "gh-aw-copilot-${{ github.workflow }}-${{ github.run_id }}"`, + description: "github.run_id makes each run unique — useful when fan-out workflows all share the same schedule trigger", + }, + { + name: "Job discriminator with claude engine and schedule trigger", + workflowData: &WorkflowData{ + On: "on:\n schedule:\n - cron: '0 9 1 * *'", + EngineConfig: &EngineConfig{ID: "claude"}, + ConcurrencyJobDiscriminator: "${{ inputs.organization || github.run_id }}", + }, + expected: `concurrency: + group: "gh-aw-claude-${{ github.workflow }}-${{ inputs.organization || github.run_id }}"`, + description: "job-discriminator works with any engine; fallback to run_id handles scheduled (no-input) runs", + }, + { + name: "Job discriminator ignored when push trigger (special trigger, no default group)", + workflowData: &WorkflowData{ + On: "on:\n push:\n branches: [main]", + EngineConfig: &EngineConfig{ID: "copilot"}, + ConcurrencyJobDiscriminator: "${{ github.run_id }}", + }, + expected: "", + description: "push is a special trigger — no default job concurrency is generated, so job-discriminator has no effect", + }, + { + name: "Job discriminator with pull_request trigger (special trigger, no default group)", + workflowData: &WorkflowData{ + On: "on:\n pull_request:\n types: [opened, synchronize]", + EngineConfig: &EngineConfig{ID: "codex"}, + ConcurrencyJobDiscriminator: "${{ github.run_id }}", + }, + expected: "", + description: "pull_request is a special trigger — job-discriminator has no effect", + }, } for _, tt := range tests {