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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/slide-deck-maintainer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions .github/workflows/slide-deck-maintainer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/stale-repo-identifier.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions .github/workflows/stale-repo-identifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1956,13 +1956,21 @@
"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"],
"required": [],
"examples": [
Comment on lines 1964 to 1967
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

Setting required: [] allows invalid concurrency objects like {} or { "cancel-in-progress": true } to pass schema validation, but GitHub Actions requires group when using the object form. Update this schema to require at least one of group or job-discriminator (e.g. via anyOf with separate required clauses), and also require group whenever cancel-in-progress is present.

Copilot uses AI. Check for mistakes.
{
"group": "dev-workflow-${{ github.ref }}",
"cancel-in-progress": true
},
{
"job-discriminator": "${{ inputs.finding_id }}"
}
]
}
Expand Down
7 changes: 7 additions & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Comment on lines +192 to +196
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

With concurrency.group no longer required by the schema, workflowData.Concurrency can now be present but contain no group (e.g. only cancel-in-progress). extractConcurrencyGroupFromYAML will return empty string, so the current validation silently accepts an invalid GitHub Actions concurrency object. Add a guard here to fail validation when workflowData.Concurrency is non-empty but no group can be extracted, and/or explicitly validate that cancel-in-progress is never set without group.

Copilot uses AI. Check for mistakes.

// Validate engine-level concurrency group expression
log.Printf("Validating engine-level concurrency configuration")
if workflowData.EngineConfig != nil && workflowData.EngineConfig.Concurrency != "" {
Expand Down
65 changes: 64 additions & 1 deletion pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -239,6 +240,68 @@ 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
}
}
// 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")
}
Comment on lines +295 to +303
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

After stripping job-discriminator, this function can still serialize a concurrency object that has cancel-in-progress (or other fields) but no group (e.g. frontmatter concurrency: { cancel-in-progress: true, job-discriminator: ... }). GitHub Actions requires group in the object form, so this would generate invalid lock file YAML. Consider explicitly detecting cleanMap["group"] missing and returning an error (preferred) or returning empty string / dropping the remaining fields so the compiler generates a valid default concurrency group.

Copilot uses AI. Check for mistakes.

// 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
Expand Down
138 changes: 138 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1575,3 +1575,141 @@ 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")
})

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
// 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")
})
}
Loading
Loading