diff --git a/.github/workflows/agent-performance-analyzer.lock.yml b/.github/workflows/agent-performance-analyzer.lock.yml index 8ce2e9fdb6..a1c62326c3 100644 --- a/.github/workflows/agent-performance-analyzer.lock.yml +++ b/.github/workflows/agent-performance-analyzer.lock.yml @@ -1223,7 +1223,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/meta-orchestrators" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index b052f9939d..cf3608f4cc 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -1302,7 +1302,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/audit-workflows" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/code-scanning-fixer.lock.yml b/.github/workflows/code-scanning-fixer.lock.yml index 5a537927cb..5e60fd3fea 100644 --- a/.github/workflows/code-scanning-fixer.lock.yml +++ b/.github/workflows/code-scanning-fixer.lock.yml @@ -1173,7 +1173,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/campaigns" cancel-in-progress: false outputs: patch_size_exceeded_campaigns: ${{ steps.push_repo_memory_campaigns.outputs.patch_size_exceeded }} diff --git a/.github/workflows/copilot-agent-analysis.lock.yml b/.github/workflows/copilot-agent-analysis.lock.yml index 16c85ac026..e8a1c1e0a5 100644 --- a/.github/workflows/copilot-agent-analysis.lock.yml +++ b/.github/workflows/copilot-agent-analysis.lock.yml @@ -1185,7 +1185,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/copilot-agent-analysis" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/copilot-cli-deep-research.lock.yml b/.github/workflows/copilot-cli-deep-research.lock.yml index 0a67f69a32..d1f5f3c217 100644 --- a/.github/workflows/copilot-cli-deep-research.lock.yml +++ b/.github/workflows/copilot-cli-deep-research.lock.yml @@ -1083,7 +1083,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/copilot-cli-research" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/copilot-pr-nlp-analysis.lock.yml b/.github/workflows/copilot-pr-nlp-analysis.lock.yml index 8e8f66900e..5d7caa8a9b 100644 --- a/.github/workflows/copilot-pr-nlp-analysis.lock.yml +++ b/.github/workflows/copilot-pr-nlp-analysis.lock.yml @@ -1180,7 +1180,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/nlp-analysis" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/copilot-pr-prompt-analysis.lock.yml b/.github/workflows/copilot-pr-prompt-analysis.lock.yml index 7843c85cbf..2e541a6c32 100644 --- a/.github/workflows/copilot-pr-prompt-analysis.lock.yml +++ b/.github/workflows/copilot-pr-prompt-analysis.lock.yml @@ -1116,7 +1116,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/prompt-analysis" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/copilot-session-insights.lock.yml b/.github/workflows/copilot-session-insights.lock.yml index b21b5479db..b06cd86050 100644 --- a/.github/workflows/copilot-session-insights.lock.yml +++ b/.github/workflows/copilot-session-insights.lock.yml @@ -1248,7 +1248,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/session-insights" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/daily-cli-performance.lock.yml b/.github/workflows/daily-cli-performance.lock.yml index d2cb761e3c..f07f034f60 100644 --- a/.github/workflows/daily-cli-performance.lock.yml +++ b/.github/workflows/daily-cli-performance.lock.yml @@ -1329,7 +1329,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/cli-performance" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/daily-code-metrics.lock.yml b/.github/workflows/daily-code-metrics.lock.yml index f72a397dd8..8535246494 100644 --- a/.github/workflows/daily-code-metrics.lock.yml +++ b/.github/workflows/daily-code-metrics.lock.yml @@ -1223,7 +1223,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|daily/daily-code-metrics" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/daily-community-attribution.lock.yml b/.github/workflows/daily-community-attribution.lock.yml index 7255647695..55e34c3154 100644 --- a/.github/workflows/daily-community-attribution.lock.yml +++ b/.github/workflows/daily-community-attribution.lock.yml @@ -1129,7 +1129,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|master" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/daily-copilot-token-report.lock.yml b/.github/workflows/daily-copilot-token-report.lock.yml index 7a3f19c244..65c334493c 100644 --- a/.github/workflows/daily-copilot-token-report.lock.yml +++ b/.github/workflows/daily-copilot-token-report.lock.yml @@ -1185,7 +1185,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/token-metrics" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/daily-news.lock.yml b/.github/workflows/daily-news.lock.yml index cfbc20bfab..50f6b382d9 100644 --- a/.github/workflows/daily-news.lock.yml +++ b/.github/workflows/daily-news.lock.yml @@ -1256,7 +1256,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/daily-news" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/daily-testify-uber-super-expert.lock.yml b/.github/workflows/daily-testify-uber-super-expert.lock.yml index 0b96052c6b..f3554d0153 100644 --- a/.github/workflows/daily-testify-uber-super-expert.lock.yml +++ b/.github/workflows/daily-testify-uber-super-expert.lock.yml @@ -1212,7 +1212,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/testify-expert" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index 4cd12565ac..a99a7afcb9 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -1284,7 +1284,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/deep-report" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/delight.lock.yml b/.github/workflows/delight.lock.yml index cf81c8f20f..3198f00a6d 100644 --- a/.github/workflows/delight.lock.yml +++ b/.github/workflows/delight.lock.yml @@ -1128,7 +1128,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/delight" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/developer-docs-consolidator.lock.yml b/.github/workflows/developer-docs-consolidator.lock.yml index 91cc4aade9..4fd925f0ad 100644 --- a/.github/workflows/developer-docs-consolidator.lock.yml +++ b/.github/workflows/developer-docs-consolidator.lock.yml @@ -1453,7 +1453,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|master" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/discussion-task-miner.lock.yml b/.github/workflows/discussion-task-miner.lock.yml index cf28d21f93..9a300321e8 100644 --- a/.github/workflows/discussion-task-miner.lock.yml +++ b/.github/workflows/discussion-task-miner.lock.yml @@ -1111,7 +1111,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/discussion-task-miner" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/firewall-escape.lock.yml b/.github/workflows/firewall-escape.lock.yml index 067c12788f..bc6c82a522 100644 --- a/.github/workflows/firewall-escape.lock.yml +++ b/.github/workflows/firewall-escape.lock.yml @@ -1186,7 +1186,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/firewall-escape" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/glossary-maintainer.lock.yml b/.github/workflows/glossary-maintainer.lock.yml index 9a3d5c6c55..39b0d2dc28 100644 --- a/.github/workflows/glossary-maintainer.lock.yml +++ b/.github/workflows/glossary-maintainer.lock.yml @@ -1348,7 +1348,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|master" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/metrics-collector.lock.yml b/.github/workflows/metrics-collector.lock.yml index 06ac3960bc..cc284f308d 100644 --- a/.github/workflows/metrics-collector.lock.yml +++ b/.github/workflows/metrics-collector.lock.yml @@ -685,7 +685,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/meta-orchestrators" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/pr-triage-agent.lock.yml b/.github/workflows/pr-triage-agent.lock.yml index ceab9e306a..231bebb972 100644 --- a/.github/workflows/pr-triage-agent.lock.yml +++ b/.github/workflows/pr-triage-agent.lock.yml @@ -1097,7 +1097,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/pr-triage" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/security-compliance.lock.yml b/.github/workflows/security-compliance.lock.yml index 18bb482e28..8f1a20cd91 100644 --- a/.github/workflows/security-compliance.lock.yml +++ b/.github/workflows/security-compliance.lock.yml @@ -1076,7 +1076,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/campaigns" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 2404d47110..4cf58be0ad 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -1352,7 +1352,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|master" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/weekly-blog-post-writer.lock.yml b/.github/workflows/weekly-blog-post-writer.lock.yml index 3d41d62bf1..44f48bda70 100644 --- a/.github/workflows/weekly-blog-post-writer.lock.yml +++ b/.github/workflows/weekly-blog-post-writer.lock.yml @@ -1314,7 +1314,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|master" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/.github/workflows/workflow-health-manager.lock.yml b/.github/workflows/workflow-health-manager.lock.yml index f3cc3cf707..eceac1c57a 100644 --- a/.github/workflows/workflow-health-manager.lock.yml +++ b/.github/workflows/workflow-health-manager.lock.yml @@ -1178,7 +1178,7 @@ jobs: permissions: contents: write concurrency: - group: "push-repo-memory-${{ github.repository }}" + group: "push-repo-memory-${{ github.repository }}|memory/meta-orchestrators" cancel-in-progress: false outputs: patch_size_exceeded_default: ${{ steps.push_repo_memory_default.outputs.patch_size_exceeded }} diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index 29bd7d78ec..8024fd8cb1 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -13,6 +13,7 @@ import ( "encoding/json" "fmt" "regexp" + "sort" "strings" "github.com/github/gh-aw/pkg/constants" @@ -547,6 +548,42 @@ func generateRepoMemorySteps(builder *strings.Builder, data *WorkflowData) { } } +// buildPushRepoMemoryConcurrencyGroup builds a concurrency group key that is scoped to the +// specific (target-repo, branch) pairs being written by this push job. Using the actual +// write targets—rather than a single repo-wide key—ensures that workflows pushing to +// different memory branches do not unnecessarily serialise or cancel each other. +// +// Key format: "push-repo-memory-${{ github.repository }}|[|…]" +// +// Each key component is percent-encoded (only `%` and `|` are encoded) before joining +// with "|", so the separator is always unambiguous even if a user-supplied branch name +// or target-repo contains a literal "|". For memories that target a non-default +// repository, the target repo is prepended to the branch name +// (e.g., "other-owner%2Fother-repo:memory%2Fbranch" would be encoded if needed) so that +// distinct targets produce distinct concurrency groups. The branches are sorted for a +// deterministic key regardless of the order memories are declared in the frontmatter. +func buildPushRepoMemoryConcurrencyGroup(memories []RepoMemoryEntry) string { + branchKeys := make([]string, 0, len(memories)) + for _, m := range memories { + key := encodeConcurrencyKeyPart(m.BranchName) + if m.TargetRepo != "" { + key = encodeConcurrencyKeyPart(m.TargetRepo) + ":" + key + } + branchKeys = append(branchKeys, key) + } + sort.Strings(branchKeys) + return "push-repo-memory-${{ github.repository }}|" + strings.Join(branchKeys, "|") +} + +// encodeConcurrencyKeyPart percent-encodes the characters that would otherwise make the +// concurrency group key ambiguous: "%" (to avoid double-encoding) and "|" (the separator). +// All other characters are left as-is so the key remains human-readable in workflow UIs. +func encodeConcurrencyKeyPart(s string) string { + s = strings.ReplaceAll(s, "%", "%25") + s = strings.ReplaceAll(s, "|", "%7C") + return s +} + // buildPushRepoMemoryJob creates a job that downloads repo-memory artifacts and pushes them to git branches // This job runs after the agent job completes (even if it fails) and requires contents: write permission // If threat detection is enabled, only runs if no threats were detected @@ -729,9 +766,12 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna outputs["patch_size_exceeded_"+memory.ID] = fmt.Sprintf("${{ steps.%s.outputs.patch_size_exceeded }}", stepID) } - // Serialize all push_repo_memory jobs per repository to prevent concurrent git pushes - // cancel-in-progress is false so that updates from concurrent agents are queued, not dropped - concurrency := c.indentYAMLLines("concurrency:\n group: \"push-repo-memory-${{ github.repository }}\"\n cancel-in-progress: false", " ") + // Build a concurrency key scoped to the actual branches being written. + // This prevents false serialisation between workflows that push to different memory + // branches while still serialising concurrent pushes to the *same* branch. + // cancel-in-progress is false so queued pushes are not dropped. + concurrencyGroup := buildPushRepoMemoryConcurrencyGroup(data.RepoMemoryConfig.Memories) + concurrency := c.indentYAMLLines(fmt.Sprintf("concurrency:\n group: %q\n cancel-in-progress: false", concurrencyGroup), " ") job := &Job{ Name: "push_repo_memory", diff --git a/pkg/workflow/repo_memory_test.go b/pkg/workflow/repo_memory_test.go index 3f6ee3ae86..54e89ec12d 100644 --- a/pkg/workflow/repo_memory_test.go +++ b/pkg/workflow/repo_memory_test.go @@ -1191,3 +1191,93 @@ func TestRepoMemoryNonWikiPushNoAllowedRepos(t *testing.T) { assert.NotContains(t, pushJobOutput, "REPO_MEMORY_ALLOWED_REPOS", "Non-wiki push step should not set REPO_MEMORY_ALLOWED_REPOS") } + +// TestBuildPushRepoMemoryConcurrencyGroup verifies that the concurrency group key is scoped +// to the actual branch targets, so workflows pushing to different memory branches do not +// contend with each other. +func TestBuildPushRepoMemoryConcurrencyGroup(t *testing.T) { + tests := []struct { + name string + memories []RepoMemoryEntry + expected string + }{ + { + name: "single memory uses branch name in key", + memories: []RepoMemoryEntry{ + {ID: "default", BranchName: "memory/daily-news"}, + }, + expected: "push-repo-memory-${{ github.repository }}|memory/daily-news", + }, + { + name: "multiple memories sorted for deterministic key", + memories: []RepoMemoryEntry{ + {ID: "b", BranchName: "memory/workflow-b"}, + {ID: "a", BranchName: "memory/workflow-a"}, + }, + expected: "push-repo-memory-${{ github.repository }}|memory/workflow-a|memory/workflow-b", + }, + { + name: "non-default target repo is prefixed to branch", + memories: []RepoMemoryEntry{ + {ID: "default", BranchName: "memory/shared", TargetRepo: "other-org/other-repo"}, + }, + expected: "push-repo-memory-${{ github.repository }}|other-org/other-repo:memory/shared", + }, + { + name: "mix of default and custom target repos", + memories: []RepoMemoryEntry{ + {ID: "local", BranchName: "memory/local"}, + {ID: "remote", BranchName: "memory/remote", TargetRepo: "other-org/other-repo"}, + }, + expected: "push-repo-memory-${{ github.repository }}|memory/local|other-org/other-repo:memory/remote", + }, + { + name: "branches with hyphens use pipe separator in key", + memories: []RepoMemoryEntry{ + {ID: "a", BranchName: "memory/workflow-a"}, + {ID: "b", BranchName: "memory/workflow-b"}, + }, + // This expectation documents the current use of "|" as the list separator in the key. + // If the concurrency key encoding changes (e.g., different delimiter or encoding scheme), + // update this test to match the new encoding strategy. + expected: "push-repo-memory-${{ github.repository }}|memory/workflow-a|memory/workflow-b", + }, + { + name: "pipe in branch name is percent-encoded so the separator stays unambiguous", + memories: []RepoMemoryEntry{ + {ID: "unusual", BranchName: "memory/foo|bar"}, + }, + expected: "push-repo-memory-${{ github.repository }}|memory/foo%7Cbar", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildPushRepoMemoryConcurrencyGroup(tt.memories) + assert.Equal(t, tt.expected, got, "Concurrency group key should match expected value") + }) + } +} + +// TestPushRepoMemoryJobConcurrencyKey verifies that buildPushRepoMemoryJob sets a concurrency +// key scoped to the actual memory branch rather than a repo-wide key. +func TestPushRepoMemoryJobConcurrencyKey(t *testing.T) { + data := &WorkflowData{ + RepoMemoryConfig: &RepoMemoryConfig{ + Memories: []RepoMemoryEntry{ + {ID: "default", BranchName: "memory/my-workflow"}, + }, + }, + } + + compiler := NewCompiler() + pushJob, err := compiler.buildPushRepoMemoryJob(data, false) + require.NoError(t, err, "Should build push job without error") + require.NotNil(t, pushJob, "Should produce a push job") + + assert.Contains(t, pushJob.Concurrency, "memory/my-workflow", + "Concurrency key should include the memory branch name") + // Ensure the old repo-wide-only key is not used + assert.NotContains(t, pushJob.Concurrency, "push-repo-memory-${{ github.repository }}\"", + "Concurrency key should not be the old repo-wide-only key format") +}