From 107bc7f3cdc652790ab1a9dd9925cbadb60ac74c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:57:32 +0000 Subject: [PATCH 01/11] Initial plan From ba2384f12d630cbe1d920476cf608c8e6cd5cb18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:03:24 +0000 Subject: [PATCH 02/11] Initial investigation and planning for cache-memory restore-keys fix Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/actions-lock.json | 5 +++++ .github/workflows/release.lock.yml | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index c9ea38685d2..3688f8f2b9e 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -125,6 +125,11 @@ "version": "v2.0.3", "sha": "e95548e56dfa95d4e1a28d6f422fafe75c4c26fb" }, + "docker/build-push-action@v6": { + "repo": "docker/build-push-action", + "version": "v6", + "sha": "ee4ca427a2f43b6a16632044ca514c076267da23" + }, "docker/build-push-action@v6.18.0": { "repo": "docker/build-push-action", "version": "v6.18.0", diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 3a4828e86df..626adc4d786 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -1196,7 +1196,7 @@ jobs: - name: Setup Docker Buildx (pre-validation) uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build Docker image (validation only) - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 + uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6 with: build-args: | BINARY=dist/linux-amd64 @@ -1285,7 +1285,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image (amd64) id: build - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 + uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6 with: build-args: | BINARY=dist/linux-amd64 From 69fc4318f1b891253252334c44fed2f55a8bc9b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:07:27 +0000 Subject: [PATCH 03/11] Fix cache-memory restore-keys to prevent cross-workflow cache poisoning - Modified generateCacheMemorySteps to limit restore-keys generation - Now only generates workflow-level restore keys (removes run_id only) - Prevents generic fallbacks like "memory-" that would match any workflow - Added comprehensive test suite to verify the fix Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/cache.go | 9 +- .../cache_memory_restore_keys_test.go | 181 ++++++++++++++++++ 2 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 pkg/workflow/cache_memory_restore_keys_test.go diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 188833f7ac3..0abc3c0d3e9 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -373,11 +373,14 @@ func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { } // Generate restore keys automatically by splitting the cache key on '-' + // Stop at workflow level to prevent cross-workflow cache poisoning var restoreKeys []string keyParts := strings.Split(cacheKey, "-") - for i := len(keyParts) - 1; i > 0; i-- { - restoreKey := strings.Join(keyParts[:i], "-") + "-" - restoreKeys = append(restoreKeys, restoreKey) + // Only generate restore key for workflow level (remove run_id only) + // This prevents generic fallbacks like "memory-" that would match any workflow + if len(keyParts) >= 2 { + workflowLevelKey := strings.Join(keyParts[:len(keyParts)-1], "-") + "-" + restoreKeys = append(restoreKeys, workflowLevelKey) } // Step name and action diff --git a/pkg/workflow/cache_memory_restore_keys_test.go b/pkg/workflow/cache_memory_restore_keys_test.go new file mode 100644 index 00000000000..4f8e55d2404 --- /dev/null +++ b/pkg/workflow/cache_memory_restore_keys_test.go @@ -0,0 +1,181 @@ +//go:build integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" +) + +// TestCacheMemoryRestoreKeysNoGenericFallback verifies that cache-memory restore-keys +// do NOT include a generic fallback that would match caches from other workflows. +// This prevents cross-workflow cache poisoning attacks. +func TestCacheMemoryRestoreKeysNoGenericFallback(t *testing.T) { + tests := []struct { + name string + frontmatter string + expectedInLock []string + notExpectedInLock []string + }{ + { + name: "default cache-memory should NOT have generic memory- fallback", + frontmatter: `--- +name: Test Cache Memory Restore Keys +on: workflow_dispatch +permissions: + contents: read +engine: claude +tools: + cache-memory: true + github: + allowed: [get_repository] +---`, + expectedInLock: []string{ + // Should have workflow-specific restore key + "restore-keys: |", + "memory-${{ github.workflow }}-", + }, + notExpectedInLock: []string{ + // Should NOT have generic fallback that would match other workflows + " memory-\n", + // More specific check: "memory-" followed by newline at the right indent level + }, + }, + { + name: "cache-memory with custom ID should NOT have generic fallbacks", + frontmatter: `--- +name: Test Cache Memory Custom ID +on: workflow_dispatch +permissions: + contents: read + issues: read + pull-requests: read +engine: claude +tools: + cache-memory: + - id: chroma + key: memory-chroma-${{ github.workflow }} + github: + allowed: [get_repository] +---`, + expectedInLock: []string{ + // Custom key becomes memory-chroma-${{ github.workflow }}-${{ github.run_id }} + // Restore key should only remove run_id: memory-chroma-${{ github.workflow }}- + "restore-keys: |", + "memory-chroma-${{ github.workflow }}-", + }, + notExpectedInLock: []string{ + // Should NOT have generic fallbacks that would match other workflows + " memory-chroma-\n", + " memory-\n", + }, + }, + { + name: "multiple cache-memory should NOT have generic fallbacks", + frontmatter: `--- +name: Test Multiple Cache Memory +on: workflow_dispatch +permissions: + contents: read + issues: read + pull-requests: read +engine: claude +tools: + cache-memory: + - id: default + key: memory-default-${{ github.workflow }} + - id: session + key: memory-session-${{ github.workflow }} + github: + allowed: [get_repository] +---`, + expectedInLock: []string{ + // Custom keys become memory-*-${{ github.workflow }}-${{ github.run_id }} + // Restore keys should only remove run_id + "memory-default-${{ github.workflow }}-", + "memory-session-${{ github.workflow }}-", + }, + notExpectedInLock: []string{ + // Should NOT have generic fallbacks for either cache + " memory-default-\n", + " memory-session-\n", + " memory-\n", + }, + }, + { + name: "cache-memory with threat detection should NOT have generic fallback", + frontmatter: `--- +name: Test Cache Memory with Threat Detection +on: workflow_dispatch +permissions: + contents: read +engine: claude +tools: + cache-memory: true + github: + allowed: [get_repository] +safe-outputs: + create-issue: + threat-detection: true +---`, + expectedInLock: []string{ + // Should use restore action + "uses: actions/cache/restore@", + // Should have workflow-specific restore key + "restore-keys: |", + "memory-${{ github.workflow }}-", + }, + notExpectedInLock: []string{ + // Should NOT have generic fallback + " memory-\n", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary directory + tmpDir := testutil.TempDir(t, "test-*") + + // Write the markdown file + mdPath := filepath.Join(tmpDir, "test-workflow.md") + content := tt.frontmatter + "\n\n# Test Workflow\n\nTest cache-memory restore-keys configuration.\n" + if err := os.WriteFile(mdPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test markdown file: %v", err) + } + + // Compile the workflow + compiler := NewCompiler() + if err := compiler.CompileWorkflow(mdPath); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the generated lock file + lockPath := stringutil.MarkdownToLockFile(mdPath) + lockContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockStr := string(lockContent) + + // Check expected strings + for _, expected := range tt.expectedInLock { + if !strings.Contains(lockStr, expected) { + t.Errorf("Expected to find '%s' in lock file but it was missing.\nLock file content:\n%s", expected, lockStr) + } + } + + // Check that unexpected strings are NOT present + for _, notExpected := range tt.notExpectedInLock { + if strings.Contains(lockStr, notExpected) { + t.Errorf("Did not expect to find '%s' in lock file but it was present.\nLock file content:\n%s", notExpected, lockStr) + } + } + }) + } +} From ca6676d355f30c7d22f0680b190a6b504afa5b01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:09:05 +0000 Subject: [PATCH 04/11] Recompile all workflow lock files with fixed restore-keys - Removed generic fallback restore keys from 64 workflow lock files - Each cache now only restores from the same workflow (workflow-level prefix) - Total: 82 lines deleted (generic fallback restore keys removed) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agent-persona-explorer.lock.yml | 1 - .github/workflows/audit-workflows.lock.yml | 2 -- .github/workflows/chroma-issue-indexer.lock.yml | 2 -- .github/workflows/ci-coach.lock.yml | 1 - .github/workflows/ci-doctor.lock.yml | 1 - .github/workflows/claude-code-user-docs-review.lock.yml | 1 - .github/workflows/cli-version-checker.lock.yml | 1 - .github/workflows/cloclo.lock.yml | 2 -- .github/workflows/code-scanning-fixer.lock.yml | 1 - .github/workflows/copilot-agent-analysis.lock.yml | 2 -- .github/workflows/copilot-pr-nlp-analysis.lock.yml | 2 -- .github/workflows/copilot-pr-prompt-analysis.lock.yml | 2 -- .github/workflows/copilot-session-insights.lock.yml | 1 - .github/workflows/daily-code-metrics.lock.yml | 1 - .github/workflows/daily-compiler-quality.lock.yml | 1 - .github/workflows/daily-copilot-token-report.lock.yml | 1 - .github/workflows/daily-doc-updater.lock.yml | 1 - .github/workflows/daily-firewall-report.lock.yml | 2 -- .github/workflows/daily-issues-report.lock.yml | 1 - .github/workflows/daily-mcp-concurrency-analysis.lock.yml | 1 - .github/workflows/daily-news.lock.yml | 1 - .github/workflows/daily-performance-summary.lock.yml | 2 -- .github/workflows/daily-repo-chronicle.lock.yml | 1 - .github/workflows/daily-safe-output-optimizer.lock.yml | 1 - .github/workflows/deep-report.lock.yml | 2 -- .github/workflows/developer-docs-consolidator.lock.yml | 2 -- .github/workflows/firewall-escape.lock.yml | 1 - .github/workflows/github-mcp-structural-analysis.lock.yml | 1 - .github/workflows/github-mcp-tools-report.lock.yml | 1 - .github/workflows/glossary-maintainer.lock.yml | 1 - .github/workflows/go-fan.lock.yml | 1 - .github/workflows/go-logger.lock.yml | 1 - .github/workflows/grumpy-reviewer.lock.yml | 1 - .github/workflows/instructions-janitor.lock.yml | 1 - .github/workflows/jsweep.lock.yml | 1 - .github/workflows/lockfile-stats.lock.yml | 1 - .github/workflows/mcp-inspector.lock.yml | 1 - .github/workflows/org-health-report.lock.yml | 1 - .github/workflows/pdf-summary.lock.yml | 1 - .github/workflows/poem-bot.lock.yml | 2 -- .github/workflows/portfolio-analyst.lock.yml | 2 -- .github/workflows/pr-nitpick-reviewer.lock.yml | 1 - .github/workflows/prompt-clustering-analysis.lock.yml | 2 -- .github/workflows/python-data-charts.lock.yml | 1 - .github/workflows/q.lock.yml | 1 - .github/workflows/repo-audit-analyzer.lock.yml | 2 -- .github/workflows/repository-quality-improver.lock.yml | 2 -- .github/workflows/safe-output-health.lock.yml | 1 - .github/workflows/schema-consistency-checker.lock.yml | 3 --- .github/workflows/scout.lock.yml | 1 - .github/workflows/security-review.lock.yml | 1 - .github/workflows/sergo.lock.yml | 1 - .github/workflows/slide-deck-maintainer.lock.yml | 1 - .github/workflows/smoke-claude.lock.yml | 1 - .github/workflows/smoke-codex.lock.yml | 1 - .github/workflows/smoke-copilot.lock.yml | 1 - .github/workflows/stale-repo-identifier.lock.yml | 2 -- .github/workflows/static-analysis-report.lock.yml | 1 - .github/workflows/step-name-alignment.lock.yml | 1 - .github/workflows/super-linter.lock.yml | 1 - .github/workflows/technical-doc-writer.lock.yml | 1 - .github/workflows/test-create-pr-error-handling.lock.yml | 1 - .github/workflows/unbloat-docs.lock.yml | 1 - .github/workflows/weekly-issue-summary.lock.yml | 1 - pkg/workflow/data/action_pins.json | 5 +++++ 65 files changed, 5 insertions(+), 82 deletions(-) diff --git a/.github/workflows/agent-persona-explorer.lock.yml b/.github/workflows/agent-persona-explorer.lock.yml index 23014bec0d4..23b191bde7b 100644 --- a/.github/workflows/agent-persona-explorer.lock.yml +++ b/.github/workflows/agent-persona-explorer.lock.yml @@ -158,7 +158,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index b55ccb5d57d..344c82e10f1 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -182,8 +182,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | trending-data-${{ github.workflow }}- - trending-data- - trending- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/chroma-issue-indexer.lock.yml b/.github/workflows/chroma-issue-indexer.lock.yml index 7a15b473e9f..b6978bd20e9 100644 --- a/.github/workflows/chroma-issue-indexer.lock.yml +++ b/.github/workflows/chroma-issue-indexer.lock.yml @@ -110,8 +110,6 @@ jobs: path: /tmp/gh-aw/cache-memory-chroma restore-keys: | memory-chroma-${{ github.workflow }}- - memory-chroma- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/ci-coach.lock.yml b/.github/workflows/ci-coach.lock.yml index de9a4fc5e3e..2c760f694b8 100644 --- a/.github/workflows/ci-coach.lock.yml +++ b/.github/workflows/ci-coach.lock.yml @@ -166,7 +166,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 4d40e4c84b7..6fd46c0b3f8 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -138,7 +138,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/claude-code-user-docs-review.lock.yml b/.github/workflows/claude-code-user-docs-review.lock.yml index 18f213892ab..6ec04ccda7a 100644 --- a/.github/workflows/claude-code-user-docs-review.lock.yml +++ b/.github/workflows/claude-code-user-docs-review.lock.yml @@ -124,7 +124,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml index 69aea5f398b..e4ba45d06cf 100644 --- a/.github/workflows/cli-version-checker.lock.yml +++ b/.github/workflows/cli-version-checker.lock.yml @@ -128,7 +128,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 0ee5b525c81..5ad96a4096a 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -225,8 +225,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | cloclo-memory-${{ github.workflow }}- - cloclo-memory- - cloclo- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/code-scanning-fixer.lock.yml b/.github/workflows/code-scanning-fixer.lock.yml index 9a6c3226db7..d624672c03b 100644 --- a/.github/workflows/code-scanning-fixer.lock.yml +++ b/.github/workflows/code-scanning-fixer.lock.yml @@ -123,7 +123,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (campaigns) env: diff --git a/.github/workflows/copilot-agent-analysis.lock.yml b/.github/workflows/copilot-agent-analysis.lock.yml index 3fb3208558d..3ff73f39802 100644 --- a/.github/workflows/copilot-agent-analysis.lock.yml +++ b/.github/workflows/copilot-agent-analysis.lock.yml @@ -135,8 +135,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | copilot-pr-data- - copilot-pr- - copilot- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/copilot-pr-nlp-analysis.lock.yml b/.github/workflows/copilot-pr-nlp-analysis.lock.yml index d9f87c205fb..064dc5349d3 100644 --- a/.github/workflows/copilot-pr-nlp-analysis.lock.yml +++ b/.github/workflows/copilot-pr-nlp-analysis.lock.yml @@ -164,8 +164,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | copilot-pr-data- - copilot-pr- - copilot- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/copilot-pr-prompt-analysis.lock.yml b/.github/workflows/copilot-pr-prompt-analysis.lock.yml index ff8ea40e9b9..7155ebf2da5 100644 --- a/.github/workflows/copilot-pr-prompt-analysis.lock.yml +++ b/.github/workflows/copilot-pr-prompt-analysis.lock.yml @@ -135,8 +135,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | copilot-pr-data- - copilot-pr- - copilot- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/copilot-session-insights.lock.yml b/.github/workflows/copilot-session-insights.lock.yml index 2c55a773654..71eef863b01 100644 --- a/.github/workflows/copilot-session-insights.lock.yml +++ b/.github/workflows/copilot-session-insights.lock.yml @@ -160,7 +160,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/daily-code-metrics.lock.yml b/.github/workflows/daily-code-metrics.lock.yml index 2556b2b0f4a..6786213f509 100644 --- a/.github/workflows/daily-code-metrics.lock.yml +++ b/.github/workflows/daily-code-metrics.lock.yml @@ -149,7 +149,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/daily-compiler-quality.lock.yml b/.github/workflows/daily-compiler-quality.lock.yml index f6f92828066..2fe3edbc546 100644 --- a/.github/workflows/daily-compiler-quality.lock.yml +++ b/.github/workflows/daily-compiler-quality.lock.yml @@ -124,7 +124,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/daily-copilot-token-report.lock.yml b/.github/workflows/daily-copilot-token-report.lock.yml index e173925332d..f2f4fe88496 100644 --- a/.github/workflows/daily-copilot-token-report.lock.yml +++ b/.github/workflows/daily-copilot-token-report.lock.yml @@ -182,7 +182,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/daily-doc-updater.lock.yml b/.github/workflows/daily-doc-updater.lock.yml index d943e004cc9..3f0024ddba5 100644 --- a/.github/workflows/daily-doc-updater.lock.yml +++ b/.github/workflows/daily-doc-updater.lock.yml @@ -123,7 +123,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 4d3faa42fec..e989e678c93 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -181,8 +181,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | trending-data-${{ github.workflow }}- - trending-data- - trending- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index e59240668ac..c9ca1704ed0 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -162,7 +162,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/daily-mcp-concurrency-analysis.lock.yml b/.github/workflows/daily-mcp-concurrency-analysis.lock.yml index 450ddd4ae93..2672ec232c4 100644 --- a/.github/workflows/daily-mcp-concurrency-analysis.lock.yml +++ b/.github/workflows/daily-mcp-concurrency-analysis.lock.yml @@ -124,7 +124,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/daily-news.lock.yml b/.github/workflows/daily-news.lock.yml index 3beb4543e8f..82426810281 100644 --- a/.github/workflows/daily-news.lock.yml +++ b/.github/workflows/daily-news.lock.yml @@ -220,7 +220,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/daily-performance-summary.lock.yml b/.github/workflows/daily-performance-summary.lock.yml index ec50bb41626..6ef1790afce 100644 --- a/.github/workflows/daily-performance-summary.lock.yml +++ b/.github/workflows/daily-performance-summary.lock.yml @@ -151,8 +151,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | trending-data-${{ github.workflow }}- - trending-data- - trending- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/daily-repo-chronicle.lock.yml b/.github/workflows/daily-repo-chronicle.lock.yml index ac9c91043b6..bb5f8780e26 100644 --- a/.github/workflows/daily-repo-chronicle.lock.yml +++ b/.github/workflows/daily-repo-chronicle.lock.yml @@ -149,7 +149,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/daily-safe-output-optimizer.lock.yml b/.github/workflows/daily-safe-output-optimizer.lock.yml index a1c928655c0..019dcd3ed23 100644 --- a/.github/workflows/daily-safe-output-optimizer.lock.yml +++ b/.github/workflows/daily-safe-output-optimizer.lock.yml @@ -166,7 +166,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index 6162d00a3e0..948a2d37ea1 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -166,8 +166,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | weekly-issues-data- - weekly-issues- - weekly- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/developer-docs-consolidator.lock.yml b/.github/workflows/developer-docs-consolidator.lock.yml index fb40b035e8a..4ef124a90bb 100644 --- a/.github/workflows/developer-docs-consolidator.lock.yml +++ b/.github/workflows/developer-docs-consolidator.lock.yml @@ -125,8 +125,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | developer-docs-cache- - developer-docs- - developer- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/firewall-escape.lock.yml b/.github/workflows/firewall-escape.lock.yml index 9b4ad6f4843..b70c4724112 100644 --- a/.github/workflows/firewall-escape.lock.yml +++ b/.github/workflows/firewall-escape.lock.yml @@ -133,7 +133,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: diff --git a/.github/workflows/github-mcp-structural-analysis.lock.yml b/.github/workflows/github-mcp-structural-analysis.lock.yml index 54659e16c96..6bd01e446c8 100644 --- a/.github/workflows/github-mcp-structural-analysis.lock.yml +++ b/.github/workflows/github-mcp-structural-analysis.lock.yml @@ -150,7 +150,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/github-mcp-tools-report.lock.yml b/.github/workflows/github-mcp-tools-report.lock.yml index 9f0cd109335..2f77526e639 100644 --- a/.github/workflows/github-mcp-tools-report.lock.yml +++ b/.github/workflows/github-mcp-tools-report.lock.yml @@ -127,7 +127,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/glossary-maintainer.lock.yml b/.github/workflows/glossary-maintainer.lock.yml index 38f918cdf7c..7c16c323a4e 100644 --- a/.github/workflows/glossary-maintainer.lock.yml +++ b/.github/workflows/glossary-maintainer.lock.yml @@ -136,7 +136,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/go-fan.lock.yml b/.github/workflows/go-fan.lock.yml index 8db9dc7bd5e..cfc8aa058eb 100644 --- a/.github/workflows/go-fan.lock.yml +++ b/.github/workflows/go-fan.lock.yml @@ -124,7 +124,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/go-logger.lock.yml b/.github/workflows/go-logger.lock.yml index 93599ebcac3..aa5169e9665 100644 --- a/.github/workflows/go-logger.lock.yml +++ b/.github/workflows/go-logger.lock.yml @@ -140,7 +140,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index 1bef6e4b19e..5ee401251f0 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -157,7 +157,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/instructions-janitor.lock.yml b/.github/workflows/instructions-janitor.lock.yml index ba08075adbf..581d28ca7a2 100644 --- a/.github/workflows/instructions-janitor.lock.yml +++ b/.github/workflows/instructions-janitor.lock.yml @@ -123,7 +123,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/jsweep.lock.yml b/.github/workflows/jsweep.lock.yml index 61a5228b372..8e7e28efb59 100644 --- a/.github/workflows/jsweep.lock.yml +++ b/.github/workflows/jsweep.lock.yml @@ -133,7 +133,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/lockfile-stats.lock.yml b/.github/workflows/lockfile-stats.lock.yml index e742a799c53..52d56eb6d57 100644 --- a/.github/workflows/lockfile-stats.lock.yml +++ b/.github/workflows/lockfile-stats.lock.yml @@ -124,7 +124,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index 2c59a3356a5..6bbdfc1c1db 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -180,7 +180,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index 4fb1e786a0a..bff71e2d70b 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -153,7 +153,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index a5e8f73264c..7e4cb19e0b2 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -174,7 +174,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 5ed797eada5..99b4122472d 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -162,8 +162,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | poem-memory-${{ github.workflow }}- - poem-memory- - poem- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/portfolio-analyst.lock.yml b/.github/workflows/portfolio-analyst.lock.yml index 784b95114ec..2fb995c54cf 100644 --- a/.github/workflows/portfolio-analyst.lock.yml +++ b/.github/workflows/portfolio-analyst.lock.yml @@ -188,8 +188,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | trending-data-${{ github.workflow }}- - trending-data- - trending- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/pr-nitpick-reviewer.lock.yml b/.github/workflows/pr-nitpick-reviewer.lock.yml index 0d0168b887c..77c2e5ae992 100644 --- a/.github/workflows/pr-nitpick-reviewer.lock.yml +++ b/.github/workflows/pr-nitpick-reviewer.lock.yml @@ -173,7 +173,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/prompt-clustering-analysis.lock.yml b/.github/workflows/prompt-clustering-analysis.lock.yml index 079a87c579c..6cb9bd55549 100644 --- a/.github/workflows/prompt-clustering-analysis.lock.yml +++ b/.github/workflows/prompt-clustering-analysis.lock.yml @@ -210,8 +210,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | trending-data-${{ github.workflow }}- - trending-data- - trending- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index 0a72f0e98c5..370f4e00d20 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -177,7 +177,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index c89a186c744..798def25259 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -214,7 +214,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/repo-audit-analyzer.lock.yml b/.github/workflows/repo-audit-analyzer.lock.yml index 0b34d86f59e..ba7c22c8315 100644 --- a/.github/workflows/repo-audit-analyzer.lock.yml +++ b/.github/workflows/repo-audit-analyzer.lock.yml @@ -129,8 +129,6 @@ jobs: path: /tmp/gh-aw/cache-memory-repo-audits restore-keys: | repo-audits-${{ github.workflow }}- - repo-audits- - repo- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/repository-quality-improver.lock.yml b/.github/workflows/repository-quality-improver.lock.yml index cbf7faaf108..f4539e0ca0f 100644 --- a/.github/workflows/repository-quality-improver.lock.yml +++ b/.github/workflows/repository-quality-improver.lock.yml @@ -125,8 +125,6 @@ jobs: path: /tmp/gh-aw/cache-memory-focus-areas restore-keys: | quality-focus-${{ github.workflow }}- - quality-focus- - quality- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/safe-output-health.lock.yml b/.github/workflows/safe-output-health.lock.yml index 6cc552640fd..bafcba83ace 100644 --- a/.github/workflows/safe-output-health.lock.yml +++ b/.github/workflows/safe-output-health.lock.yml @@ -163,7 +163,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/schema-consistency-checker.lock.yml b/.github/workflows/schema-consistency-checker.lock.yml index f2817e76fef..26f6c7b59d0 100644 --- a/.github/workflows/schema-consistency-checker.lock.yml +++ b/.github/workflows/schema-consistency-checker.lock.yml @@ -125,9 +125,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | schema-consistency-cache-${{ github.workflow }}- - schema-consistency-cache- - schema-consistency- - schema- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 4301e4c7f6d..95f35f96cb7 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -201,7 +201,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/security-review.lock.yml b/.github/workflows/security-review.lock.yml index 6f9a91112ee..084f56935bf 100644 --- a/.github/workflows/security-review.lock.yml +++ b/.github/workflows/security-review.lock.yml @@ -191,7 +191,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/sergo.lock.yml b/.github/workflows/sergo.lock.yml index a526f3669f2..39c7008dbed 100644 --- a/.github/workflows/sergo.lock.yml +++ b/.github/workflows/sergo.lock.yml @@ -125,7 +125,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/slide-deck-maintainer.lock.yml b/.github/workflows/slide-deck-maintainer.lock.yml index 147b67194d7..526d85d1a00 100644 --- a/.github/workflows/slide-deck-maintainer.lock.yml +++ b/.github/workflows/slide-deck-maintainer.lock.yml @@ -141,7 +141,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 88355f0bab0..e23819db124 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -191,7 +191,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index c2acf79b15c..fbf56b19ca8 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -154,7 +154,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index db104ad85c8..26050248ea3 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -187,7 +187,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index a22723d433d..90b687f58c1 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -194,8 +194,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | trending-data-${{ github.workflow }}- - trending-data- - trending- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 27772003d13..365f2f99603 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -162,7 +162,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/step-name-alignment.lock.yml b/.github/workflows/step-name-alignment.lock.yml index 4bebd973f0c..43adcc8420b 100644 --- a/.github/workflows/step-name-alignment.lock.yml +++ b/.github/workflows/step-name-alignment.lock.yml @@ -123,7 +123,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index 4200193e45b..81634036fd8 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -132,7 +132,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 437a5499c22..e3773783ef7 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -155,7 +155,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/test-create-pr-error-handling.lock.yml b/.github/workflows/test-create-pr-error-handling.lock.yml index ec94d449aa7..8c403f4fe75 100644 --- a/.github/workflows/test-create-pr-error-handling.lock.yml +++ b/.github/workflows/test-create-pr-error-handling.lock.yml @@ -120,7 +120,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index 74474550b78..535f983bc90 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -165,7 +165,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index 89ea30704a4..e9aab2df814 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -146,7 +146,6 @@ jobs: path: /tmp/gh-aw/cache-memory restore-keys: | memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index c9ea38685d2..3688f8f2b9e 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -125,6 +125,11 @@ "version": "v2.0.3", "sha": "e95548e56dfa95d4e1a28d6f422fafe75c4c26fb" }, + "docker/build-push-action@v6": { + "repo": "docker/build-push-action", + "version": "v6", + "sha": "ee4ca427a2f43b6a16632044ca514c076267da23" + }, "docker/build-push-action@v6.18.0": { "repo": "docker/build-push-action", "version": "v6.18.0", From c75fd6fc7c21ba1f67c81ab0812caabd4c024493 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:12:50 +0000 Subject: [PATCH 05/11] Address code review feedback: improve test robustness and add clarifying comments - Added clarifying comment about cache key structure - Improved test to use pattern matching instead of exact whitespace - Added helper function hasGenericRestoreKey for robust restore key validation - Tests now check for generic fallback patterns regardless of formatting Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/cache.go | 2 + .../cache_memory_restore_keys_test.go | 64 ++++++++++++------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 0abc3c0d3e9..a06e1f66fea 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -378,6 +378,8 @@ func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { keyParts := strings.Split(cacheKey, "-") // Only generate restore key for workflow level (remove run_id only) // This prevents generic fallbacks like "memory-" that would match any workflow + // Note: cacheKey is always constructed with at least 2 parts (prefix + run_id) + // Example: "memory-${{ github.workflow }}-${{ github.run_id }}" splits into 3+ parts if len(keyParts) >= 2 { workflowLevelKey := strings.Join(keyParts[:len(keyParts)-1], "-") + "-" restoreKeys = append(restoreKeys, workflowLevelKey) diff --git a/pkg/workflow/cache_memory_restore_keys_test.go b/pkg/workflow/cache_memory_restore_keys_test.go index 4f8e55d2404..fc95c4aefeb 100644 --- a/pkg/workflow/cache_memory_restore_keys_test.go +++ b/pkg/workflow/cache_memory_restore_keys_test.go @@ -5,6 +5,7 @@ package workflow import ( "os" "path/filepath" + "regexp" "strings" "testing" @@ -12,6 +13,36 @@ import ( "github.com/github/gh-aw/pkg/testutil" ) +// hasGenericRestoreKey checks if the lock file contains a generic restore key pattern +// that would match caches from other workflows. Returns true if found (which is bad). +func hasGenericRestoreKey(lockContent, prefix string) bool { + // Look for restore-keys sections + restoreKeysPattern := regexp.MustCompile(`restore-keys:\s*\|`) + matches := restoreKeysPattern.FindAllStringIndex(lockContent, -1) + + for _, match := range matches { + // Get the content after "restore-keys: |" + start := match[1] + // Find the next non-indented line (which marks the end of restore-keys) + lines := strings.Split(lockContent[start:], "\n") + for _, line := range lines { + // Check if this line is a restore key (starts with whitespace) + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") { + restoreKey := strings.TrimSpace(line) + // Check if this is a generic fallback (ends with just the prefix and nothing else) + // For example: "memory-" (bad) vs "memory-${{ github.workflow }}-" (good) + if restoreKey == prefix { + return true + } + } else if strings.TrimSpace(line) != "" { + // We've hit a non-restore-key line, stop checking this section + break + } + } + } + return false +} + // TestCacheMemoryRestoreKeysNoGenericFallback verifies that cache-memory restore-keys // do NOT include a generic fallback that would match caches from other workflows. // This prevents cross-workflow cache poisoning attacks. @@ -21,6 +52,7 @@ func TestCacheMemoryRestoreKeysNoGenericFallback(t *testing.T) { frontmatter string expectedInLock []string notExpectedInLock []string + genericFallbacks []string // Generic restore key prefixes that should NOT be present }{ { name: "default cache-memory should NOT have generic memory- fallback", @@ -40,11 +72,7 @@ tools: "restore-keys: |", "memory-${{ github.workflow }}-", }, - notExpectedInLock: []string{ - // Should NOT have generic fallback that would match other workflows - " memory-\n", - // More specific check: "memory-" followed by newline at the right indent level - }, + genericFallbacks: []string{"memory-"}, }, { name: "cache-memory with custom ID should NOT have generic fallbacks", @@ -69,11 +97,7 @@ tools: "restore-keys: |", "memory-chroma-${{ github.workflow }}-", }, - notExpectedInLock: []string{ - // Should NOT have generic fallbacks that would match other workflows - " memory-chroma-\n", - " memory-\n", - }, + genericFallbacks: []string{"memory-chroma-", "memory-"}, }, { name: "multiple cache-memory should NOT have generic fallbacks", @@ -100,12 +124,7 @@ tools: "memory-default-${{ github.workflow }}-", "memory-session-${{ github.workflow }}-", }, - notExpectedInLock: []string{ - // Should NOT have generic fallbacks for either cache - " memory-default-\n", - " memory-session-\n", - " memory-\n", - }, + genericFallbacks: []string{"memory-default-", "memory-session-", "memory-"}, }, { name: "cache-memory with threat detection should NOT have generic fallback", @@ -130,10 +149,7 @@ safe-outputs: "restore-keys: |", "memory-${{ github.workflow }}-", }, - notExpectedInLock: []string{ - // Should NOT have generic fallback - " memory-\n", - }, + genericFallbacks: []string{"memory-"}, }, } @@ -170,10 +186,10 @@ safe-outputs: } } - // Check that unexpected strings are NOT present - for _, notExpected := range tt.notExpectedInLock { - if strings.Contains(lockStr, notExpected) { - t.Errorf("Did not expect to find '%s' in lock file but it was present.\nLock file content:\n%s", notExpected, lockStr) + // Check that generic fallback restore keys are NOT present using the helper + for _, genericFallback := range tt.genericFallbacks { + if hasGenericRestoreKey(lockStr, genericFallback) { + t.Errorf("Found generic restore key '%s' in lock file, which creates a security vulnerability.\nLock file content:\n%s", genericFallback, lockStr) } } }) From 8e8f7beeb968874253c02c595d2f51ce9a37c5d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:42:52 +0000 Subject: [PATCH 06/11] Address code review feedback and add scope field - Fixed trailing whitespace on line 22 of test file - Removed unused notExpectedInLock field from test struct - Added 'scope' field to CacheMemoryEntry (workflow/repo) - Updated schema to include scope field with enum validation - Modified restore key generation to handle both scopes: - workflow (default): Only workflow-level restore keys (secure) - repo: Multiple restore keys for cross-workflow sharing - Added test case for repo scope feature - Added clarifying comments about scope security implications Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 12 ++++ pkg/workflow/cache.go | 58 ++++++++++++++++--- .../cache_memory_restore_keys_test.go | 38 ++++++++++-- 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 77da432af36..ad53b9bace3 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3121,6 +3121,12 @@ "restore-only": { "type": "boolean", "description": "If true, only restore the cache without saving it back. Uses actions/cache/restore instead of actions/cache. No artifact upload step will be generated." + }, + "scope": { + "type": "string", + "enum": ["workflow", "repo"], + "default": "workflow", + "description": "Cache restore key scope: 'workflow' (default, only restores from same workflow) or 'repo' (restores from any workflow in the repository). Use 'repo' with caution as it allows cross-workflow cache sharing." } }, "additionalProperties": false, @@ -3161,6 +3167,12 @@ "restore-only": { "type": "boolean", "description": "If true, only restore the cache without saving it back. Uses actions/cache/restore instead of actions/cache. No artifact upload step will be generated." + }, + "scope": { + "type": "string", + "enum": ["workflow", "repo"], + "default": "workflow", + "description": "Cache restore key scope: 'workflow' (default, only restores from same workflow) or 'repo' (restores from any workflow in the repository). Use 'repo' with caution as it allows cross-workflow cache sharing." } }, "required": ["id", "key"], diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index a06e1f66fea..e63d3915da3 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -23,6 +23,7 @@ type CacheMemoryEntry struct { Description string `yaml:"description,omitempty"` // optional description for this cache RetentionDays *int `yaml:"retention-days,omitempty"` // retention days for upload-artifact action RestoreOnly bool `yaml:"restore-only,omitempty"` // if true, only restore cache without saving + Scope string `yaml:"scope,omitempty"` // scope for restore keys: "workflow" (default) or "repo" } // generateDefaultCacheKey generates a default cache key for a given cache ID @@ -141,6 +142,17 @@ func (c *Compiler) extractCacheMemoryConfig(toolsConfig *ToolsConfig) (*CacheMem } } + // Parse scope field + if scope, exists := cacheMap["scope"]; exists { + if scopeStr, ok := scope.(string); ok { + entry.Scope = scopeStr + } + } + // Default to "workflow" scope if not specified + if entry.Scope == "" { + entry.Scope = "workflow" + } + config.Caches = append(config.Caches, entry) } } @@ -206,6 +218,17 @@ func (c *Compiler) extractCacheMemoryConfig(toolsConfig *ToolsConfig) (*CacheMem } } + // Parse scope field + if scope, exists := configMap["scope"]; exists { + if scopeStr, ok := scope.(string); ok { + entry.Scope = scopeStr + } + } + // Default to "workflow" scope if not specified + if entry.Scope == "" { + entry.Scope = "workflow" + } + config.Caches = []CacheMemoryEntry{entry} return config, nil } @@ -372,17 +395,34 @@ func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { cacheKey = cacheKey + runIdSuffix } - // Generate restore keys automatically by splitting the cache key on '-' - // Stop at workflow level to prevent cross-workflow cache poisoning + // Generate restore keys based on scope + // - "workflow" (default): Removes only run_id, keeping workflow identifier + // - "repo": Generates generic prefix that matches across all workflows in the repo var restoreKeys []string keyParts := strings.Split(cacheKey, "-") - // Only generate restore key for workflow level (remove run_id only) - // This prevents generic fallbacks like "memory-" that would match any workflow - // Note: cacheKey is always constructed with at least 2 parts (prefix + run_id) - // Example: "memory-${{ github.workflow }}-${{ github.run_id }}" splits into 3+ parts - if len(keyParts) >= 2 { - workflowLevelKey := strings.Join(keyParts[:len(keyParts)-1], "-") + "-" - restoreKeys = append(restoreKeys, workflowLevelKey) + + // Determine scope (default to "workflow" for safety) + scope := cache.Scope + if scope == "" { + scope = "workflow" + } + + if scope == "repo" { + // For repo scope, generate progressively shorter restore keys + // This allows cache sharing across workflows in the same repository + for i := len(keyParts) - 1; i > 0; i-- { + restoreKey := strings.Join(keyParts[:i], "-") + "-" + restoreKeys = append(restoreKeys, restoreKey) + } + } else { + // For workflow scope (default), only remove run_id to prevent cross-workflow cache access + // This prevents generic fallbacks like "memory-" that would match any workflow + // Security: Custom keys without ${{ github.workflow }} will still be workflow-scoped + // by only removing the final run_id segment + if len(keyParts) >= 2 { + workflowLevelKey := strings.Join(keyParts[:len(keyParts)-1], "-") + "-" + restoreKeys = append(restoreKeys, workflowLevelKey) + } } // Step name and action diff --git a/pkg/workflow/cache_memory_restore_keys_test.go b/pkg/workflow/cache_memory_restore_keys_test.go index fc95c4aefeb..0b74966986c 100644 --- a/pkg/workflow/cache_memory_restore_keys_test.go +++ b/pkg/workflow/cache_memory_restore_keys_test.go @@ -19,7 +19,7 @@ func hasGenericRestoreKey(lockContent, prefix string) bool { // Look for restore-keys sections restoreKeysPattern := regexp.MustCompile(`restore-keys:\s*\|`) matches := restoreKeysPattern.FindAllStringIndex(lockContent, -1) - + for _, match := range matches { // Get the content after "restore-keys: |" start := match[1] @@ -48,11 +48,10 @@ func hasGenericRestoreKey(lockContent, prefix string) bool { // This prevents cross-workflow cache poisoning attacks. func TestCacheMemoryRestoreKeysNoGenericFallback(t *testing.T) { tests := []struct { - name string - frontmatter string - expectedInLock []string - notExpectedInLock []string - genericFallbacks []string // Generic restore key prefixes that should NOT be present + name string + frontmatter string + expectedInLock []string + genericFallbacks []string // Generic restore key prefixes that should NOT be present }{ { name: "default cache-memory should NOT have generic memory- fallback", @@ -151,6 +150,33 @@ safe-outputs: }, genericFallbacks: []string{"memory-"}, }, + { + name: "cache-memory with repo scope should allow generic fallbacks", + frontmatter: `--- +name: Test Cache Memory Repo Scope +on: workflow_dispatch +permissions: + contents: read + issues: read + pull-requests: read +engine: claude +tools: + cache-memory: + - id: shared + key: shared-cache-${{ github.workflow }} + scope: repo + github: + allowed: [get_repository] +---`, + expectedInLock: []string{ + // Should have multiple restore keys for repo scope + "restore-keys: |", + "shared-cache-${{ github.workflow }}-", + "shared-cache-", + "shared-", + }, + genericFallbacks: []string{}, // No checks - repo scope intentionally allows generic fallbacks + }, } for _, tt := range tests { From 08cb62f649a5e5e9f92f3ae8e8094433b02dcac1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:52:05 +0000 Subject: [PATCH 07/11] Add strict mode validation for cache-memory scope: repo - Reject scope: repo in cache-memory when strict mode is enabled - Prevents cross-workflow cache poisoning in production workflows - Added validation in validateStrictTools function - Added comprehensive unit tests covering object and array notation - Clear error message guides users to use scope: workflow instead Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/strict_mode_validation.go | 33 ++++++ pkg/workflow/strict_mode_validation_test.go | 111 ++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/pkg/workflow/strict_mode_validation.go b/pkg/workflow/strict_mode_validation.go index e579b8cca8e..6bea468ada2 100644 --- a/pkg/workflow/strict_mode_validation.go +++ b/pkg/workflow/strict_mode_validation.go @@ -178,6 +178,39 @@ func (c *Compiler) validateStrictTools(frontmatter map[string]any) error { } } + // Check if cache-memory is configured with scope: repo + cacheMemoryValue, hasCacheMemory := toolsMap["cache-memory"] + if hasCacheMemory { + // Helper function to check scope in a cache entry + checkScope := func(cacheMap map[string]any) error { + if scope, hasScope := cacheMap["scope"]; hasScope { + if scopeStr, ok := scope.(string); ok && scopeStr == "repo" { + strictModeValidationLog.Printf("Cache-memory repo scope validation failed") + return fmt.Errorf("strict mode: cache-memory with 'scope: repo' is not allowed for security reasons. Repo scope allows cache sharing across all workflows in the repository, which can enable cross-workflow cache poisoning attacks. Use 'scope: workflow' (default) instead, which isolates caches to individual workflows. See: https://github.github.com/gh-aw/reference/tools/#cache-memory") + } + } + return nil + } + + // Check if cache-memory is a map (object notation) + if cacheMemoryConfig, ok := cacheMemoryValue.(map[string]any); ok { + if err := checkScope(cacheMemoryConfig); err != nil { + return err + } + } + + // Check if cache-memory is an array (array notation) + if cacheMemoryArray, ok := cacheMemoryValue.([]any); ok { + for _, item := range cacheMemoryArray { + if cacheMap, ok := item.(map[string]any); ok { + if err := checkScope(cacheMap); err != nil { + return err + } + } + } + } + } + return nil } diff --git a/pkg/workflow/strict_mode_validation_test.go b/pkg/workflow/strict_mode_validation_test.go index 7fd4ed438bf..ebc28607b28 100644 --- a/pkg/workflow/strict_mode_validation_test.go +++ b/pkg/workflow/strict_mode_validation_test.go @@ -551,3 +551,114 @@ func TestValidateStrictModeEdgeCases(t *testing.T) { }) } } + +// TestValidateStrictCacheMemoryScope tests that cache-memory with scope: repo is rejected in strict mode +func TestValidateStrictCacheMemoryScope(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectError bool + errorMsg string + }{ + { + name: "cache-memory with workflow scope is allowed", + frontmatter: map[string]any{ + "on": "push", + "tools": map[string]any{ + "cache-memory": map[string]any{ + "key": "memory-test", + "scope": "workflow", + }, + }, + }, + expectError: false, + }, + { + name: "cache-memory without scope (defaults to workflow) is allowed", + frontmatter: map[string]any{ + "on": "push", + "tools": map[string]any{ + "cache-memory": map[string]any{ + "key": "memory-test", + }, + }, + }, + expectError: false, + }, + { + name: "cache-memory with repo scope is rejected", + frontmatter: map[string]any{ + "on": "push", + "tools": map[string]any{ + "cache-memory": map[string]any{ + "key": "memory-test", + "scope": "repo", + }, + }, + }, + expectError: true, + errorMsg: "strict mode: cache-memory with 'scope: repo' is not allowed for security reasons", + }, + { + name: "cache-memory array with repo scope is rejected", + frontmatter: map[string]any{ + "on": "push", + "tools": map[string]any{ + "cache-memory": []any{ + map[string]any{ + "id": "default", + "key": "memory-default", + "scope": "workflow", + }, + map[string]any{ + "id": "shared", + "key": "memory-shared", + "scope": "repo", + }, + }, + }, + }, + expectError: true, + errorMsg: "strict mode: cache-memory with 'scope: repo' is not allowed for security reasons", + }, + { + name: "cache-memory array with all workflow scope is allowed", + frontmatter: map[string]any{ + "on": "push", + "tools": map[string]any{ + "cache-memory": []any{ + map[string]any{ + "id": "default", + "key": "memory-default", + "scope": "workflow", + }, + map[string]any{ + "id": "logs", + "key": "memory-logs", + }, + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + compiler.strictMode = true + + err := compiler.validateStrictTools(tt.frontmatter) + + if tt.expectError && err == nil { + t.Error("Expected validation to fail but it succeeded") + } else if !tt.expectError && err != nil { + t.Errorf("Expected validation to succeed but it failed: %v", err) + } else if tt.expectError && err != nil && tt.errorMsg != "" { + if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error containing '%s', got '%s'", tt.errorMsg, err.Error()) + } + } + }) + } +} From f4d980e5d84559f8ba7f3388832879ea7a9f5ce9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:12:55 +0000 Subject: [PATCH 08/11] Simplify restore key generation - treat key as single unit - Changed restore key generation to NOT split the cache key - Both workflow and repo scopes now generate identical restore keys - Simply removes the run_id suffix as a complete unit - The semantic difference (workflow vs repo) is enforced via strict mode - Updated test to reflect new behavior: repo scope no longer generates multiple progressive fallbacks - Addresses feedback: "do not split the initial restore key, treat it as one key" Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/cache.go | 29 +- pkg/workflow/cache.go.bak | 724 ++++++++++++++++++ .../cache_memory_restore_keys_test.go | 10 +- 3 files changed, 744 insertions(+), 19 deletions(-) create mode 100644 pkg/workflow/cache.go.bak diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index e63d3915da3..8b93231c8a6 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -396,10 +396,9 @@ func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { } // Generate restore keys based on scope - // - "workflow" (default): Removes only run_id, keeping workflow identifier - // - "repo": Generates generic prefix that matches across all workflows in the repo + // - "workflow" (default): Removes only run_id suffix, keeping workflow identifier + // - "repo": Removes only run_id suffix, allowing repo-wide cache sharing var restoreKeys []string - keyParts := strings.Split(cacheKey, "-") // Determine scope (default to "workflow" for safety) scope := cache.Scope @@ -407,24 +406,26 @@ func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { scope = "workflow" } - if scope == "repo" { - // For repo scope, generate progressively shorter restore keys - // This allows cache sharing across workflows in the same repository - for i := len(keyParts) - 1; i > 0; i-- { - restoreKey := strings.Join(keyParts[:i], "-") + "-" - restoreKeys = append(restoreKeys, restoreKey) - } + // For both scopes, remove the run_id suffix as a single unit (don't split the key) + // The cacheKey always ends with "-${{ github.run_id }}" (ensured by code above) + if strings.HasSuffix(cacheKey, runIdSuffix) { + // Remove the run_id suffix to create the restore key + restoreKey := strings.TrimSuffix(cacheKey, "${{ github.run_id }}") // Keep the trailing "-" + restoreKeys = append(restoreKeys, restoreKey) } else { - // For workflow scope (default), only remove run_id to prevent cross-workflow cache access - // This prevents generic fallbacks like "memory-" that would match any workflow - // Security: Custom keys without ${{ github.workflow }} will still be workflow-scoped - // by only removing the final run_id segment + // Fallback: split on last dash if run_id suffix not found + // This handles edge cases where the key format might be different + keyParts := strings.Split(cacheKey, "-") if len(keyParts) >= 2 { workflowLevelKey := strings.Join(keyParts[:len(keyParts)-1], "-") + "-" restoreKeys = append(restoreKeys, workflowLevelKey) } } + // Note: The difference between "workflow" and "repo" scope is semantic + // Both generate the same restore key pattern, but "repo" scope indicates + // the user's intent to share caches across workflows (and is rejected in strict mode) + // Step name and action // Use actions/cache/restore for restore-only caches or when threat detection is enabled // When threat detection is enabled, we only restore the cache and defer saving to a separate job after detection diff --git a/pkg/workflow/cache.go.bak b/pkg/workflow/cache.go.bak new file mode 100644 index 00000000000..e63d3915da3 --- /dev/null +++ b/pkg/workflow/cache.go.bak @@ -0,0 +1,724 @@ +package workflow + +import ( + "fmt" + "os" + "strings" + + "github.com/github/gh-aw/pkg/logger" + "github.com/goccy/go-yaml" +) + +var cacheLog = logger.New("workflow:cache") + +// CacheMemoryConfig holds configuration for cache-memory functionality +type CacheMemoryConfig struct { + Caches []CacheMemoryEntry `yaml:"caches,omitempty"` // cache configurations +} + +// CacheMemoryEntry represents a single cache-memory configuration +type CacheMemoryEntry struct { + ID string `yaml:"id"` // cache identifier (required for array notation) + Key string `yaml:"key,omitempty"` // custom cache key + Description string `yaml:"description,omitempty"` // optional description for this cache + RetentionDays *int `yaml:"retention-days,omitempty"` // retention days for upload-artifact action + RestoreOnly bool `yaml:"restore-only,omitempty"` // if true, only restore cache without saving + Scope string `yaml:"scope,omitempty"` // scope for restore keys: "workflow" (default) or "repo" +} + +// generateDefaultCacheKey generates a default cache key for a given cache ID +func generateDefaultCacheKey(cacheID string) string { + if cacheID == "default" { + return "memory-${{ github.workflow }}-${{ github.run_id }}" + } + return fmt.Sprintf("memory-%s-${{ github.workflow }}-${{ github.run_id }}", cacheID) +} + +// extractCacheMemoryConfig extracts cache-memory configuration from tools section +// Updated to use ToolsConfig instead of map[string]any +func (c *Compiler) extractCacheMemoryConfig(toolsConfig *ToolsConfig) (*CacheMemoryConfig, error) { + // Check if cache-memory tool is configured + if toolsConfig == nil || toolsConfig.CacheMemory == nil { + return nil, nil + } + + cacheLog.Print("Extracting cache-memory configuration from ToolsConfig") + + config := &CacheMemoryConfig{} + cacheMemoryValue := toolsConfig.CacheMemory.Raw + + // Handle nil value (simple enable with defaults) - same as true + // This handles the case where cache-memory: is specified without a value + if cacheMemoryValue == nil { + config.Caches = []CacheMemoryEntry{ + { + ID: "default", + Key: generateDefaultCacheKey("default"), + }, + } + return config, nil + } + + // Handle boolean value (simple enable/disable) + if boolValue, ok := cacheMemoryValue.(bool); ok { + if boolValue { + // Create a single default cache entry + config.Caches = []CacheMemoryEntry{ + { + ID: "default", + Key: generateDefaultCacheKey("default"), + }, + } + } + // If false, return empty config (empty array means disabled) + return config, nil + } + + // Handle array of cache configurations + if cacheArray, ok := cacheMemoryValue.([]any); ok { + cacheLog.Printf("Processing cache array with %d entries", len(cacheArray)) + config.Caches = make([]CacheMemoryEntry, 0, len(cacheArray)) + for _, item := range cacheArray { + if cacheMap, ok := item.(map[string]any); ok { + entry := CacheMemoryEntry{} + + // ID is required for array notation + if id, exists := cacheMap["id"]; exists { + if idStr, ok := id.(string); ok { + entry.ID = idStr + } + } + // Use "default" if no ID specified + if entry.ID == "" { + entry.ID = "default" + } + + // Parse custom key + if key, exists := cacheMap["key"]; exists { + if keyStr, ok := key.(string); ok { + entry.Key = keyStr + // Automatically append -${{ github.run_id }} if the key doesn't already end with it + runIdSuffix := "-${{ github.run_id }}" + if !strings.HasSuffix(entry.Key, runIdSuffix) { + entry.Key = entry.Key + runIdSuffix + } + } + } + // Set default key if not specified + if entry.Key == "" { + entry.Key = generateDefaultCacheKey(entry.ID) + } + + // Parse description + if description, exists := cacheMap["description"]; exists { + if descStr, ok := description.(string); ok { + entry.Description = descStr + } + } + + // Parse retention days + if retentionDays, exists := cacheMap["retention-days"]; exists { + if retentionDaysInt, ok := retentionDays.(int); ok { + entry.RetentionDays = &retentionDaysInt + } else if retentionDaysFloat, ok := retentionDays.(float64); ok { + retentionDaysIntValue := int(retentionDaysFloat) + entry.RetentionDays = &retentionDaysIntValue + } else if retentionDaysUint64, ok := retentionDays.(uint64); ok { + retentionDaysIntValue := int(retentionDaysUint64) + entry.RetentionDays = &retentionDaysIntValue + } + // Validate retention-days bounds + if entry.RetentionDays != nil { + if err := validateIntRange(*entry.RetentionDays, 1, 90, "retention-days"); err != nil { + return nil, err + } + } + } + + // Parse restore-only flag + if restoreOnly, exists := cacheMap["restore-only"]; exists { + if restoreOnlyBool, ok := restoreOnly.(bool); ok { + entry.RestoreOnly = restoreOnlyBool + } + } + + // Parse scope field + if scope, exists := cacheMap["scope"]; exists { + if scopeStr, ok := scope.(string); ok { + entry.Scope = scopeStr + } + } + // Default to "workflow" scope if not specified + if entry.Scope == "" { + entry.Scope = "workflow" + } + + config.Caches = append(config.Caches, entry) + } + } + + // Check for duplicate cache IDs + if err := validateNoDuplicateCacheIDs(config.Caches); err != nil { + return nil, err + } + + return config, nil + } + + // Handle object configuration (single cache, backward compatible) + // Convert to array with single entry + if configMap, ok := cacheMemoryValue.(map[string]any); ok { + entry := CacheMemoryEntry{ + ID: "default", + Key: generateDefaultCacheKey("default"), + } + + // Parse custom key + if key, exists := configMap["key"]; exists { + if keyStr, ok := key.(string); ok { + entry.Key = keyStr + // Automatically append -${{ github.run_id }} if the key doesn't already end with it + runIdSuffix := "-${{ github.run_id }}" + if !strings.HasSuffix(entry.Key, runIdSuffix) { + entry.Key = entry.Key + runIdSuffix + } + } + } + + // Parse description + if description, exists := configMap["description"]; exists { + if descStr, ok := description.(string); ok { + entry.Description = descStr + } + } + + // Parse retention days + if retentionDays, exists := configMap["retention-days"]; exists { + if retentionDaysInt, ok := retentionDays.(int); ok { + entry.RetentionDays = &retentionDaysInt + } else if retentionDaysFloat, ok := retentionDays.(float64); ok { + retentionDaysIntValue := int(retentionDaysFloat) + entry.RetentionDays = &retentionDaysIntValue + } else if retentionDaysUint64, ok := retentionDays.(uint64); ok { + retentionDaysIntValue := int(retentionDaysUint64) + entry.RetentionDays = &retentionDaysIntValue + } + // Validate retention-days bounds + if entry.RetentionDays != nil { + if err := validateIntRange(*entry.RetentionDays, 1, 90, "retention-days"); err != nil { + return nil, err + } + } + } + + // Parse restore-only flag + if restoreOnly, exists := configMap["restore-only"]; exists { + if restoreOnlyBool, ok := restoreOnly.(bool); ok { + entry.RestoreOnly = restoreOnlyBool + } + } + + // Parse scope field + if scope, exists := configMap["scope"]; exists { + if scopeStr, ok := scope.(string); ok { + entry.Scope = scopeStr + } + } + // Default to "workflow" scope if not specified + if entry.Scope == "" { + entry.Scope = "workflow" + } + + config.Caches = []CacheMemoryEntry{entry} + return config, nil + } + + return nil, nil +} + +// extractCacheMemoryConfigFromMap is a backward compatibility wrapper for extractCacheMemoryConfig +// that accepts map[string]any instead of *ToolsConfig. This allows gradual migration of calling code. +func (c *Compiler) extractCacheMemoryConfigFromMap(tools map[string]any) (*CacheMemoryConfig, error) { + toolsConfig, err := ParseToolsConfig(tools) + if err != nil { + return nil, err + } + return c.extractCacheMemoryConfig(toolsConfig) +} + +// generateCacheSteps generates cache steps for the workflow based on cache configuration +func generateCacheSteps(builder *strings.Builder, data *WorkflowData, verbose bool) { + if data.Cache == "" { + return + } + + // Add comment indicating cache configuration was processed + builder.WriteString(" # Cache configuration from frontmatter processed below\n") + + // Parse cache configuration to determine if it's a single cache or array + var caches []map[string]any + + // Try to parse the cache YAML string back to determine structure + var topLevel map[string]any + if err := yaml.Unmarshal([]byte(data.Cache), &topLevel); err != nil { + if verbose { + fmt.Fprintf(os.Stderr, "Warning: Failed to parse cache configuration: %v\n", err) + } + return + } + + // Extract the cache section from the top-level map + cacheConfig, exists := topLevel["cache"] + if !exists { + if verbose { + fmt.Fprintf(os.Stderr, "Warning: No cache key found in parsed configuration\n") + } + return + } + + // Handle both single cache object and array of caches + if cacheArray, isArray := cacheConfig.([]any); isArray { + // Multiple caches + for _, cacheItem := range cacheArray { + if cacheMap, ok := cacheItem.(map[string]any); ok { + caches = append(caches, cacheMap) + } + } + } else if cacheMap, isMap := cacheConfig.(map[string]any); isMap { + // Single cache + caches = append(caches, cacheMap) + } + + // Generate cache steps + for i, cache := range caches { + stepName := "Cache" + if len(caches) > 1 { + stepName = fmt.Sprintf("Cache %d", i+1) + } + if key, hasKey := cache["key"]; hasKey { + if keyStr, ok := key.(string); ok && keyStr != "" { + stepName = fmt.Sprintf("Cache (%s)", keyStr) + } + } + + fmt.Fprintf(builder, " - name: %s\n", stepName) + fmt.Fprintf(builder, " uses: %s\n", GetActionPin("actions/cache")) + builder.WriteString(" with:\n") + + // Add required cache parameters + if key, hasKey := cache["key"]; hasKey { + fmt.Fprintf(builder, " key: %v\n", key) + } + if path, hasPath := cache["path"]; hasPath { + if pathArray, isArray := path.([]any); isArray { + builder.WriteString(" path: |\n") + for _, p := range pathArray { + fmt.Fprintf(builder, " %v\n", p) + } + } else { + fmt.Fprintf(builder, " path: %v\n", path) + } + } + + // Add optional cache parameters + if restoreKeys, hasRestoreKeys := cache["restore-keys"]; hasRestoreKeys { + if restoreArray, isArray := restoreKeys.([]any); isArray { + builder.WriteString(" restore-keys: |\n") + for _, key := range restoreArray { + fmt.Fprintf(builder, " %v\n", key) + } + } else { + fmt.Fprintf(builder, " restore-keys: %v\n", restoreKeys) + } + } + if uploadChunkSize, hasSize := cache["upload-chunk-size"]; hasSize { + fmt.Fprintf(builder, " upload-chunk-size: %v\n", uploadChunkSize) + } + if failOnMiss, hasFail := cache["fail-on-cache-miss"]; hasFail { + fmt.Fprintf(builder, " fail-on-cache-miss: %v\n", failOnMiss) + } + if lookupOnly, hasLookup := cache["lookup-only"]; hasLookup { + fmt.Fprintf(builder, " lookup-only: %v\n", lookupOnly) + } + } +} + +// generateCacheMemorySteps generates cache setup steps (directory creation and restore) for the cache-memory configuration +// Cache-memory provides a simple file share that LLMs can read/write freely +// Artifact upload is handled separately by generateCacheMemoryArtifactUpload after agent execution +func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { + if data.CacheMemoryConfig == nil || len(data.CacheMemoryConfig.Caches) == 0 { + return + } + + cacheLog.Printf("Generating cache-memory setup steps for %d caches", len(data.CacheMemoryConfig.Caches)) + + builder.WriteString(" # Cache memory file share configuration from frontmatter processed below\n") + + // Use backward-compatible paths only when there's a single cache with ID "default" + // This maintains compatibility with existing workflows + useBackwardCompatiblePaths := len(data.CacheMemoryConfig.Caches) == 1 && data.CacheMemoryConfig.Caches[0].ID == "default" + + for _, cache := range data.CacheMemoryConfig.Caches { + // Default cache uses /tmp/gh-aw/cache-memory/ for backward compatibility + // Other caches use /tmp/gh-aw/cache-memory-{id}/ to prevent overlaps + var cacheDir string + if cache.ID == "default" { + cacheDir = "/tmp/gh-aw/cache-memory" + } else { + cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s", cache.ID) + } + + // Add step to create cache-memory directory for this cache + if useBackwardCompatiblePaths { + // For single default cache, use the original directory for backward compatibility + builder.WriteString(" - name: Create cache-memory directory\n") + builder.WriteString(" run: bash /opt/gh-aw/actions/create_cache_memory_dir.sh\n") + } else { + fmt.Fprintf(builder, " - name: Create cache-memory directory (%s)\n", cache.ID) + builder.WriteString(" run: |\n") + fmt.Fprintf(builder, " mkdir -p %s\n", cacheDir) + } + + cacheKey := cache.Key + if cacheKey == "" { + if useBackwardCompatiblePaths { + cacheKey = "memory-${{ github.workflow }}-${{ github.run_id }}" + } else { + cacheKey = fmt.Sprintf("memory-%s-${{ github.workflow }}-${{ github.run_id }}", cache.ID) + } + } + + // Automatically append -${{ github.run_id }} if the key doesn't already end with it + runIdSuffix := "-${{ github.run_id }}" + if !strings.HasSuffix(cacheKey, runIdSuffix) { + cacheKey = cacheKey + runIdSuffix + } + + // Generate restore keys based on scope + // - "workflow" (default): Removes only run_id, keeping workflow identifier + // - "repo": Generates generic prefix that matches across all workflows in the repo + var restoreKeys []string + keyParts := strings.Split(cacheKey, "-") + + // Determine scope (default to "workflow" for safety) + scope := cache.Scope + if scope == "" { + scope = "workflow" + } + + if scope == "repo" { + // For repo scope, generate progressively shorter restore keys + // This allows cache sharing across workflows in the same repository + for i := len(keyParts) - 1; i > 0; i-- { + restoreKey := strings.Join(keyParts[:i], "-") + "-" + restoreKeys = append(restoreKeys, restoreKey) + } + } else { + // For workflow scope (default), only remove run_id to prevent cross-workflow cache access + // This prevents generic fallbacks like "memory-" that would match any workflow + // Security: Custom keys without ${{ github.workflow }} will still be workflow-scoped + // by only removing the final run_id segment + if len(keyParts) >= 2 { + workflowLevelKey := strings.Join(keyParts[:len(keyParts)-1], "-") + "-" + restoreKeys = append(restoreKeys, workflowLevelKey) + } + } + + // Step name and action + // Use actions/cache/restore for restore-only caches or when threat detection is enabled + // When threat detection is enabled, we only restore the cache and defer saving to a separate job after detection + // Use actions/cache for normal caches (which auto-saves via post-action) + threatDetectionEnabled := data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil + useRestoreOnly := cache.RestoreOnly || threatDetectionEnabled + + var actionName string + if useRestoreOnly { + actionName = "Restore cache-memory file share data" + } else { + actionName = "Cache cache-memory file share data" + } + + if useBackwardCompatiblePaths { + fmt.Fprintf(builder, " - name: %s\n", actionName) + } else { + fmt.Fprintf(builder, " - name: %s (%s)\n", actionName, cache.ID) + } + + // Use actions/cache/restore@v4 when restore-only or threat detection enabled + // Use actions/cache@v4 for normal caches + if useRestoreOnly { + fmt.Fprintf(builder, " uses: %s\n", GetActionPin("actions/cache/restore")) + } else { + fmt.Fprintf(builder, " uses: %s\n", GetActionPin("actions/cache")) + } + builder.WriteString(" with:\n") + fmt.Fprintf(builder, " key: %s\n", cacheKey) + + // Path - always use the new cache directory format + fmt.Fprintf(builder, " path: %s\n", cacheDir) + + builder.WriteString(" restore-keys: |\n") + for _, key := range restoreKeys { + fmt.Fprintf(builder, " %s\n", key) + } + } +} + +// generateCacheMemoryArtifactUpload generates artifact upload steps for cache-memory +// This should be called after agent execution steps to ensure cache is uploaded after the agent has finished +func generateCacheMemoryArtifactUpload(builder *strings.Builder, data *WorkflowData) { + if data.CacheMemoryConfig == nil || len(data.CacheMemoryConfig.Caches) == 0 { + return + } + + // Only upload artifacts when threat detection is enabled (needed for update_cache_memory job) + // When threat detection is disabled, cache is saved automatically by actions/cache post-action + threatDetectionEnabled := data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil + if !threatDetectionEnabled { + cacheLog.Print("Skipping cache-memory artifact upload (threat detection disabled)") + return + } + + cacheLog.Printf("Generating cache-memory artifact upload steps for %d caches", len(data.CacheMemoryConfig.Caches)) + + // Use backward-compatible paths only when there's a single cache with ID "default" + useBackwardCompatiblePaths := len(data.CacheMemoryConfig.Caches) == 1 && data.CacheMemoryConfig.Caches[0].ID == "default" + + for _, cache := range data.CacheMemoryConfig.Caches { + // Skip restore-only caches + if cache.RestoreOnly { + continue + } + + // Default cache uses /tmp/gh-aw/cache-memory/ for backward compatibility + // Other caches use /tmp/gh-aw/cache-memory-{id}/ to prevent overlaps + var cacheDir string + if cache.ID == "default" { + cacheDir = "/tmp/gh-aw/cache-memory" + } else { + cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s", cache.ID) + } + + // Add upload-artifact step for each cache (runs always) + if useBackwardCompatiblePaths { + builder.WriteString(" - name: Upload cache-memory data as artifact\n") + } else { + fmt.Fprintf(builder, " - name: Upload cache-memory data as artifact (%s)\n", cache.ID) + } + fmt.Fprintf(builder, " uses: %s\n", GetActionPin("actions/upload-artifact")) + builder.WriteString(" if: always()\n") + builder.WriteString(" with:\n") + // Always use the new artifact name and path format + if useBackwardCompatiblePaths { + builder.WriteString(" name: cache-memory\n") + } else { + fmt.Fprintf(builder, " name: cache-memory-%s\n", cache.ID) + } + fmt.Fprintf(builder, " path: %s\n", cacheDir) + // Add retention-days if configured + if cache.RetentionDays != nil { + fmt.Fprintf(builder, " retention-days: %d\n", *cache.RetentionDays) + } + } +} + +// buildCacheMemoryPromptSection builds a PromptSection for cache memory instructions +// Returns a PromptSection that references a template file with substitutions, or nil if no cache is configured +func buildCacheMemoryPromptSection(config *CacheMemoryConfig) *PromptSection { + if config == nil || len(config.Caches) == 0 { + return nil + } + + // Check if there's only one cache with ID "default" to use singular template + if len(config.Caches) == 1 && config.Caches[0].ID == "default" { + cache := config.Caches[0] + cacheDir := "/tmp/gh-aw/cache-memory/" + + // Build description text + descriptionText := "" + if cache.Description != "" { + descriptionText = " " + cache.Description + } + + cacheLog.Printf("Building cache memory prompt section with env vars: cache_dir=%s, description=%s", cacheDir, descriptionText) + + // Return prompt section with template file and environment variables for substitution + return &PromptSection{ + Content: cacheMemoryPromptFile, + IsFile: true, + EnvVars: map[string]string{ + "GH_AW_CACHE_DIR": cacheDir, + "GH_AW_CACHE_DESCRIPTION": descriptionText, + }, + } + } + + // Multiple caches or non-default single cache - generate content inline + var content strings.Builder + content.WriteString("\n") + content.WriteString("---\n") + content.WriteString("\n") + content.WriteString("## Cache Folders Available\n") + content.WriteString("\n") + content.WriteString("You have access to persistent cache folders where you can read and write files to create memories and store information:\n") + content.WriteString("\n") + + // List all caches + for _, cache := range config.Caches { + var cacheDir string + if cache.ID == "default" { + cacheDir = "/tmp/gh-aw/cache-memory/" + } else { + cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s/", cache.ID) + } + if cache.Description != "" { + fmt.Fprintf(&content, "- **%s**: `%s` - %s\n", cache.ID, cacheDir, cache.Description) + } else { + fmt.Fprintf(&content, "- **%s**: `%s`\n", cache.ID, cacheDir) + } + } + + content.WriteString("\n") + content.WriteString("- **Read/Write Access**: You can freely read from and write to any files in these folders\n") + content.WriteString("- **Persistence**: Files in these folders persist across workflow runs via GitHub Actions cache\n") + content.WriteString("- **Last Write Wins**: If multiple processes write to the same file, the last write will be preserved\n") + content.WriteString("- **File Share**: Use these as simple file shares - organize files as you see fit\n") + content.WriteString("\n") + content.WriteString("Examples of what you can store:\n") + + // Add examples for each cache + for _, cache := range config.Caches { + var cacheDir string + if cache.ID == "default" { + cacheDir = "/tmp/gh-aw/cache-memory" + } else { + cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s", cache.ID) + } + fmt.Fprintf(&content, "- `%s/notes.txt` - general notes and observations\n", cacheDir) + fmt.Fprintf(&content, "- `%s/preferences.json` - user preferences and settings\n", cacheDir) + fmt.Fprintf(&content, "- `%s/state/` - organized state files in subdirectories\n", cacheDir) + } + + content.WriteString("\n") + content.WriteString("Feel free to create, read, update, and organize files in these folders as needed for your tasks.\n") + + return &PromptSection{ + Content: content.String(), + IsFile: false, + } +} + +// buildUpdateCacheMemoryJob builds a job that updates cache-memory after detection passes +// This job downloads cache-memory artifacts and saves them to GitHub Actions cache +func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetectionEnabled bool) (*Job, error) { + if data.CacheMemoryConfig == nil || len(data.CacheMemoryConfig.Caches) == 0 { + return nil, nil + } + + // Only create this job if threat detection is enabled + // Otherwise, cache is updated automatically by actions/cache post-action + if !threatDetectionEnabled { + return nil, nil + } + + cacheLog.Printf("Building update_cache_memory job for %d caches (threatDetectionEnabled=%v)", len(data.CacheMemoryConfig.Caches), threatDetectionEnabled) + + var steps []string + + // Build steps for each cache + for _, cache := range data.CacheMemoryConfig.Caches { + // Skip restore-only caches + if cache.RestoreOnly { + continue + } + + // Determine artifact name and cache directory + var artifactName, cacheDir string + if cache.ID == "default" { + artifactName = "cache-memory" + cacheDir = "/tmp/gh-aw/cache-memory" + } else { + artifactName = fmt.Sprintf("cache-memory-%s", cache.ID) + cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s", cache.ID) + } + + // Download artifact step + var downloadStep strings.Builder + fmt.Fprintf(&downloadStep, " - name: Download cache-memory artifact (%s)\n", cache.ID) + fmt.Fprintf(&downloadStep, " uses: %s\n", GetActionPin("actions/download-artifact")) + downloadStep.WriteString(" continue-on-error: true\n") + downloadStep.WriteString(" with:\n") + fmt.Fprintf(&downloadStep, " name: %s\n", artifactName) + fmt.Fprintf(&downloadStep, " path: %s\n", cacheDir) + steps = append(steps, downloadStep.String()) + + // Generate cache key (same logic as in generateCacheMemorySteps) + cacheKey := cache.Key + if cacheKey == "" { + if cache.ID == "default" { + cacheKey = "memory-${{ github.workflow }}-${{ github.run_id }}" + } else { + cacheKey = fmt.Sprintf("memory-%s-${{ github.workflow }}-${{ github.run_id }}", cache.ID) + } + } + + // Automatically append -${{ github.run_id }} if the key doesn't already end with it + runIdSuffix := "-${{ github.run_id }}" + if !strings.HasSuffix(cacheKey, runIdSuffix) { + cacheKey = cacheKey + runIdSuffix + } + + // Save to cache step + var saveStep strings.Builder + fmt.Fprintf(&saveStep, " - name: Save cache-memory to cache (%s)\n", cache.ID) + fmt.Fprintf(&saveStep, " uses: %s\n", GetActionPin("actions/cache/save")) + saveStep.WriteString(" with:\n") + fmt.Fprintf(&saveStep, " key: %s\n", cacheKey) + fmt.Fprintf(&saveStep, " path: %s\n", cacheDir) + steps = append(steps, saveStep.String()) + } + + // If no writable caches, return nil + if len(steps) == 0 { + return nil, nil + } + + // Add setup step to copy scripts at the beginning + var setupSteps []string + setupActionRef := c.resolveActionReference("./actions/setup", data) + if setupActionRef != "" || c.actionMode.IsScript() { + // For dev mode (local action path), checkout the actions folder first + setupSteps = append(setupSteps, c.generateCheckoutActionsFolder(data)...) + + // Cache restore job doesn't need project support + setupSteps = append(setupSteps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) + } + + // Prepend setup steps to all cache steps + steps = append(setupSteps, steps...) + + // Job condition: only run if detection passed + jobCondition := "always() && needs.detection.outputs.success == 'true'" + + // Set up permissions for the cache update job + // If using local actions (dev mode without action-tag), we need contents: read to checkout the actions folder + permissions := NewPermissionsEmpty().RenderToYAML() // Default: no special permissions needed + if setupActionRef != "" && len(c.generateCheckoutActionsFolder(data)) > 0 { + // Need contents: read to checkout the actions folder + perms := NewPermissionsContentsRead() + permissions = perms.RenderToYAML() + } + + job := &Job{ + Name: "update_cache_memory", + DisplayName: "", // No display name - job ID is sufficient + RunsOn: "runs-on: ubuntu-latest", + If: jobCondition, + Permissions: permissions, + Needs: []string{"agent", "detection"}, + Steps: steps, + } + + return job, nil +} diff --git a/pkg/workflow/cache_memory_restore_keys_test.go b/pkg/workflow/cache_memory_restore_keys_test.go index 0b74966986c..9365aee6dbb 100644 --- a/pkg/workflow/cache_memory_restore_keys_test.go +++ b/pkg/workflow/cache_memory_restore_keys_test.go @@ -151,10 +151,11 @@ safe-outputs: genericFallbacks: []string{"memory-"}, }, { - name: "cache-memory with repo scope should allow generic fallbacks", + name: "cache-memory with repo scope should have same restore key as workflow scope", frontmatter: `--- name: Test Cache Memory Repo Scope on: workflow_dispatch +strict: false permissions: contents: read issues: read @@ -169,13 +170,12 @@ tools: allowed: [get_repository] ---`, expectedInLock: []string{ - // Should have multiple restore keys for repo scope + // Repo scope now generates the same single restore key as workflow scope + // The difference is semantic (intent) and enforced in strict mode "restore-keys: |", "shared-cache-${{ github.workflow }}-", - "shared-cache-", - "shared-", }, - genericFallbacks: []string{}, // No checks - repo scope intentionally allows generic fallbacks + genericFallbacks: []string{"shared-cache-", "shared-"}, // Should NOT have these fallbacks }, } From 9715b23f2bb79e4a52f7e4d7e32364856489f7a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:14:35 +0000 Subject: [PATCH 09/11] Add cache-memory scope field and fix restore-keys security Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/cache.go.bak | 724 -------------------------------------- 1 file changed, 724 deletions(-) delete mode 100644 pkg/workflow/cache.go.bak diff --git a/pkg/workflow/cache.go.bak b/pkg/workflow/cache.go.bak deleted file mode 100644 index e63d3915da3..00000000000 --- a/pkg/workflow/cache.go.bak +++ /dev/null @@ -1,724 +0,0 @@ -package workflow - -import ( - "fmt" - "os" - "strings" - - "github.com/github/gh-aw/pkg/logger" - "github.com/goccy/go-yaml" -) - -var cacheLog = logger.New("workflow:cache") - -// CacheMemoryConfig holds configuration for cache-memory functionality -type CacheMemoryConfig struct { - Caches []CacheMemoryEntry `yaml:"caches,omitempty"` // cache configurations -} - -// CacheMemoryEntry represents a single cache-memory configuration -type CacheMemoryEntry struct { - ID string `yaml:"id"` // cache identifier (required for array notation) - Key string `yaml:"key,omitempty"` // custom cache key - Description string `yaml:"description,omitempty"` // optional description for this cache - RetentionDays *int `yaml:"retention-days,omitempty"` // retention days for upload-artifact action - RestoreOnly bool `yaml:"restore-only,omitempty"` // if true, only restore cache without saving - Scope string `yaml:"scope,omitempty"` // scope for restore keys: "workflow" (default) or "repo" -} - -// generateDefaultCacheKey generates a default cache key for a given cache ID -func generateDefaultCacheKey(cacheID string) string { - if cacheID == "default" { - return "memory-${{ github.workflow }}-${{ github.run_id }}" - } - return fmt.Sprintf("memory-%s-${{ github.workflow }}-${{ github.run_id }}", cacheID) -} - -// extractCacheMemoryConfig extracts cache-memory configuration from tools section -// Updated to use ToolsConfig instead of map[string]any -func (c *Compiler) extractCacheMemoryConfig(toolsConfig *ToolsConfig) (*CacheMemoryConfig, error) { - // Check if cache-memory tool is configured - if toolsConfig == nil || toolsConfig.CacheMemory == nil { - return nil, nil - } - - cacheLog.Print("Extracting cache-memory configuration from ToolsConfig") - - config := &CacheMemoryConfig{} - cacheMemoryValue := toolsConfig.CacheMemory.Raw - - // Handle nil value (simple enable with defaults) - same as true - // This handles the case where cache-memory: is specified without a value - if cacheMemoryValue == nil { - config.Caches = []CacheMemoryEntry{ - { - ID: "default", - Key: generateDefaultCacheKey("default"), - }, - } - return config, nil - } - - // Handle boolean value (simple enable/disable) - if boolValue, ok := cacheMemoryValue.(bool); ok { - if boolValue { - // Create a single default cache entry - config.Caches = []CacheMemoryEntry{ - { - ID: "default", - Key: generateDefaultCacheKey("default"), - }, - } - } - // If false, return empty config (empty array means disabled) - return config, nil - } - - // Handle array of cache configurations - if cacheArray, ok := cacheMemoryValue.([]any); ok { - cacheLog.Printf("Processing cache array with %d entries", len(cacheArray)) - config.Caches = make([]CacheMemoryEntry, 0, len(cacheArray)) - for _, item := range cacheArray { - if cacheMap, ok := item.(map[string]any); ok { - entry := CacheMemoryEntry{} - - // ID is required for array notation - if id, exists := cacheMap["id"]; exists { - if idStr, ok := id.(string); ok { - entry.ID = idStr - } - } - // Use "default" if no ID specified - if entry.ID == "" { - entry.ID = "default" - } - - // Parse custom key - if key, exists := cacheMap["key"]; exists { - if keyStr, ok := key.(string); ok { - entry.Key = keyStr - // Automatically append -${{ github.run_id }} if the key doesn't already end with it - runIdSuffix := "-${{ github.run_id }}" - if !strings.HasSuffix(entry.Key, runIdSuffix) { - entry.Key = entry.Key + runIdSuffix - } - } - } - // Set default key if not specified - if entry.Key == "" { - entry.Key = generateDefaultCacheKey(entry.ID) - } - - // Parse description - if description, exists := cacheMap["description"]; exists { - if descStr, ok := description.(string); ok { - entry.Description = descStr - } - } - - // Parse retention days - if retentionDays, exists := cacheMap["retention-days"]; exists { - if retentionDaysInt, ok := retentionDays.(int); ok { - entry.RetentionDays = &retentionDaysInt - } else if retentionDaysFloat, ok := retentionDays.(float64); ok { - retentionDaysIntValue := int(retentionDaysFloat) - entry.RetentionDays = &retentionDaysIntValue - } else if retentionDaysUint64, ok := retentionDays.(uint64); ok { - retentionDaysIntValue := int(retentionDaysUint64) - entry.RetentionDays = &retentionDaysIntValue - } - // Validate retention-days bounds - if entry.RetentionDays != nil { - if err := validateIntRange(*entry.RetentionDays, 1, 90, "retention-days"); err != nil { - return nil, err - } - } - } - - // Parse restore-only flag - if restoreOnly, exists := cacheMap["restore-only"]; exists { - if restoreOnlyBool, ok := restoreOnly.(bool); ok { - entry.RestoreOnly = restoreOnlyBool - } - } - - // Parse scope field - if scope, exists := cacheMap["scope"]; exists { - if scopeStr, ok := scope.(string); ok { - entry.Scope = scopeStr - } - } - // Default to "workflow" scope if not specified - if entry.Scope == "" { - entry.Scope = "workflow" - } - - config.Caches = append(config.Caches, entry) - } - } - - // Check for duplicate cache IDs - if err := validateNoDuplicateCacheIDs(config.Caches); err != nil { - return nil, err - } - - return config, nil - } - - // Handle object configuration (single cache, backward compatible) - // Convert to array with single entry - if configMap, ok := cacheMemoryValue.(map[string]any); ok { - entry := CacheMemoryEntry{ - ID: "default", - Key: generateDefaultCacheKey("default"), - } - - // Parse custom key - if key, exists := configMap["key"]; exists { - if keyStr, ok := key.(string); ok { - entry.Key = keyStr - // Automatically append -${{ github.run_id }} if the key doesn't already end with it - runIdSuffix := "-${{ github.run_id }}" - if !strings.HasSuffix(entry.Key, runIdSuffix) { - entry.Key = entry.Key + runIdSuffix - } - } - } - - // Parse description - if description, exists := configMap["description"]; exists { - if descStr, ok := description.(string); ok { - entry.Description = descStr - } - } - - // Parse retention days - if retentionDays, exists := configMap["retention-days"]; exists { - if retentionDaysInt, ok := retentionDays.(int); ok { - entry.RetentionDays = &retentionDaysInt - } else if retentionDaysFloat, ok := retentionDays.(float64); ok { - retentionDaysIntValue := int(retentionDaysFloat) - entry.RetentionDays = &retentionDaysIntValue - } else if retentionDaysUint64, ok := retentionDays.(uint64); ok { - retentionDaysIntValue := int(retentionDaysUint64) - entry.RetentionDays = &retentionDaysIntValue - } - // Validate retention-days bounds - if entry.RetentionDays != nil { - if err := validateIntRange(*entry.RetentionDays, 1, 90, "retention-days"); err != nil { - return nil, err - } - } - } - - // Parse restore-only flag - if restoreOnly, exists := configMap["restore-only"]; exists { - if restoreOnlyBool, ok := restoreOnly.(bool); ok { - entry.RestoreOnly = restoreOnlyBool - } - } - - // Parse scope field - if scope, exists := configMap["scope"]; exists { - if scopeStr, ok := scope.(string); ok { - entry.Scope = scopeStr - } - } - // Default to "workflow" scope if not specified - if entry.Scope == "" { - entry.Scope = "workflow" - } - - config.Caches = []CacheMemoryEntry{entry} - return config, nil - } - - return nil, nil -} - -// extractCacheMemoryConfigFromMap is a backward compatibility wrapper for extractCacheMemoryConfig -// that accepts map[string]any instead of *ToolsConfig. This allows gradual migration of calling code. -func (c *Compiler) extractCacheMemoryConfigFromMap(tools map[string]any) (*CacheMemoryConfig, error) { - toolsConfig, err := ParseToolsConfig(tools) - if err != nil { - return nil, err - } - return c.extractCacheMemoryConfig(toolsConfig) -} - -// generateCacheSteps generates cache steps for the workflow based on cache configuration -func generateCacheSteps(builder *strings.Builder, data *WorkflowData, verbose bool) { - if data.Cache == "" { - return - } - - // Add comment indicating cache configuration was processed - builder.WriteString(" # Cache configuration from frontmatter processed below\n") - - // Parse cache configuration to determine if it's a single cache or array - var caches []map[string]any - - // Try to parse the cache YAML string back to determine structure - var topLevel map[string]any - if err := yaml.Unmarshal([]byte(data.Cache), &topLevel); err != nil { - if verbose { - fmt.Fprintf(os.Stderr, "Warning: Failed to parse cache configuration: %v\n", err) - } - return - } - - // Extract the cache section from the top-level map - cacheConfig, exists := topLevel["cache"] - if !exists { - if verbose { - fmt.Fprintf(os.Stderr, "Warning: No cache key found in parsed configuration\n") - } - return - } - - // Handle both single cache object and array of caches - if cacheArray, isArray := cacheConfig.([]any); isArray { - // Multiple caches - for _, cacheItem := range cacheArray { - if cacheMap, ok := cacheItem.(map[string]any); ok { - caches = append(caches, cacheMap) - } - } - } else if cacheMap, isMap := cacheConfig.(map[string]any); isMap { - // Single cache - caches = append(caches, cacheMap) - } - - // Generate cache steps - for i, cache := range caches { - stepName := "Cache" - if len(caches) > 1 { - stepName = fmt.Sprintf("Cache %d", i+1) - } - if key, hasKey := cache["key"]; hasKey { - if keyStr, ok := key.(string); ok && keyStr != "" { - stepName = fmt.Sprintf("Cache (%s)", keyStr) - } - } - - fmt.Fprintf(builder, " - name: %s\n", stepName) - fmt.Fprintf(builder, " uses: %s\n", GetActionPin("actions/cache")) - builder.WriteString(" with:\n") - - // Add required cache parameters - if key, hasKey := cache["key"]; hasKey { - fmt.Fprintf(builder, " key: %v\n", key) - } - if path, hasPath := cache["path"]; hasPath { - if pathArray, isArray := path.([]any); isArray { - builder.WriteString(" path: |\n") - for _, p := range pathArray { - fmt.Fprintf(builder, " %v\n", p) - } - } else { - fmt.Fprintf(builder, " path: %v\n", path) - } - } - - // Add optional cache parameters - if restoreKeys, hasRestoreKeys := cache["restore-keys"]; hasRestoreKeys { - if restoreArray, isArray := restoreKeys.([]any); isArray { - builder.WriteString(" restore-keys: |\n") - for _, key := range restoreArray { - fmt.Fprintf(builder, " %v\n", key) - } - } else { - fmt.Fprintf(builder, " restore-keys: %v\n", restoreKeys) - } - } - if uploadChunkSize, hasSize := cache["upload-chunk-size"]; hasSize { - fmt.Fprintf(builder, " upload-chunk-size: %v\n", uploadChunkSize) - } - if failOnMiss, hasFail := cache["fail-on-cache-miss"]; hasFail { - fmt.Fprintf(builder, " fail-on-cache-miss: %v\n", failOnMiss) - } - if lookupOnly, hasLookup := cache["lookup-only"]; hasLookup { - fmt.Fprintf(builder, " lookup-only: %v\n", lookupOnly) - } - } -} - -// generateCacheMemorySteps generates cache setup steps (directory creation and restore) for the cache-memory configuration -// Cache-memory provides a simple file share that LLMs can read/write freely -// Artifact upload is handled separately by generateCacheMemoryArtifactUpload after agent execution -func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { - if data.CacheMemoryConfig == nil || len(data.CacheMemoryConfig.Caches) == 0 { - return - } - - cacheLog.Printf("Generating cache-memory setup steps for %d caches", len(data.CacheMemoryConfig.Caches)) - - builder.WriteString(" # Cache memory file share configuration from frontmatter processed below\n") - - // Use backward-compatible paths only when there's a single cache with ID "default" - // This maintains compatibility with existing workflows - useBackwardCompatiblePaths := len(data.CacheMemoryConfig.Caches) == 1 && data.CacheMemoryConfig.Caches[0].ID == "default" - - for _, cache := range data.CacheMemoryConfig.Caches { - // Default cache uses /tmp/gh-aw/cache-memory/ for backward compatibility - // Other caches use /tmp/gh-aw/cache-memory-{id}/ to prevent overlaps - var cacheDir string - if cache.ID == "default" { - cacheDir = "/tmp/gh-aw/cache-memory" - } else { - cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s", cache.ID) - } - - // Add step to create cache-memory directory for this cache - if useBackwardCompatiblePaths { - // For single default cache, use the original directory for backward compatibility - builder.WriteString(" - name: Create cache-memory directory\n") - builder.WriteString(" run: bash /opt/gh-aw/actions/create_cache_memory_dir.sh\n") - } else { - fmt.Fprintf(builder, " - name: Create cache-memory directory (%s)\n", cache.ID) - builder.WriteString(" run: |\n") - fmt.Fprintf(builder, " mkdir -p %s\n", cacheDir) - } - - cacheKey := cache.Key - if cacheKey == "" { - if useBackwardCompatiblePaths { - cacheKey = "memory-${{ github.workflow }}-${{ github.run_id }}" - } else { - cacheKey = fmt.Sprintf("memory-%s-${{ github.workflow }}-${{ github.run_id }}", cache.ID) - } - } - - // Automatically append -${{ github.run_id }} if the key doesn't already end with it - runIdSuffix := "-${{ github.run_id }}" - if !strings.HasSuffix(cacheKey, runIdSuffix) { - cacheKey = cacheKey + runIdSuffix - } - - // Generate restore keys based on scope - // - "workflow" (default): Removes only run_id, keeping workflow identifier - // - "repo": Generates generic prefix that matches across all workflows in the repo - var restoreKeys []string - keyParts := strings.Split(cacheKey, "-") - - // Determine scope (default to "workflow" for safety) - scope := cache.Scope - if scope == "" { - scope = "workflow" - } - - if scope == "repo" { - // For repo scope, generate progressively shorter restore keys - // This allows cache sharing across workflows in the same repository - for i := len(keyParts) - 1; i > 0; i-- { - restoreKey := strings.Join(keyParts[:i], "-") + "-" - restoreKeys = append(restoreKeys, restoreKey) - } - } else { - // For workflow scope (default), only remove run_id to prevent cross-workflow cache access - // This prevents generic fallbacks like "memory-" that would match any workflow - // Security: Custom keys without ${{ github.workflow }} will still be workflow-scoped - // by only removing the final run_id segment - if len(keyParts) >= 2 { - workflowLevelKey := strings.Join(keyParts[:len(keyParts)-1], "-") + "-" - restoreKeys = append(restoreKeys, workflowLevelKey) - } - } - - // Step name and action - // Use actions/cache/restore for restore-only caches or when threat detection is enabled - // When threat detection is enabled, we only restore the cache and defer saving to a separate job after detection - // Use actions/cache for normal caches (which auto-saves via post-action) - threatDetectionEnabled := data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil - useRestoreOnly := cache.RestoreOnly || threatDetectionEnabled - - var actionName string - if useRestoreOnly { - actionName = "Restore cache-memory file share data" - } else { - actionName = "Cache cache-memory file share data" - } - - if useBackwardCompatiblePaths { - fmt.Fprintf(builder, " - name: %s\n", actionName) - } else { - fmt.Fprintf(builder, " - name: %s (%s)\n", actionName, cache.ID) - } - - // Use actions/cache/restore@v4 when restore-only or threat detection enabled - // Use actions/cache@v4 for normal caches - if useRestoreOnly { - fmt.Fprintf(builder, " uses: %s\n", GetActionPin("actions/cache/restore")) - } else { - fmt.Fprintf(builder, " uses: %s\n", GetActionPin("actions/cache")) - } - builder.WriteString(" with:\n") - fmt.Fprintf(builder, " key: %s\n", cacheKey) - - // Path - always use the new cache directory format - fmt.Fprintf(builder, " path: %s\n", cacheDir) - - builder.WriteString(" restore-keys: |\n") - for _, key := range restoreKeys { - fmt.Fprintf(builder, " %s\n", key) - } - } -} - -// generateCacheMemoryArtifactUpload generates artifact upload steps for cache-memory -// This should be called after agent execution steps to ensure cache is uploaded after the agent has finished -func generateCacheMemoryArtifactUpload(builder *strings.Builder, data *WorkflowData) { - if data.CacheMemoryConfig == nil || len(data.CacheMemoryConfig.Caches) == 0 { - return - } - - // Only upload artifacts when threat detection is enabled (needed for update_cache_memory job) - // When threat detection is disabled, cache is saved automatically by actions/cache post-action - threatDetectionEnabled := data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil - if !threatDetectionEnabled { - cacheLog.Print("Skipping cache-memory artifact upload (threat detection disabled)") - return - } - - cacheLog.Printf("Generating cache-memory artifact upload steps for %d caches", len(data.CacheMemoryConfig.Caches)) - - // Use backward-compatible paths only when there's a single cache with ID "default" - useBackwardCompatiblePaths := len(data.CacheMemoryConfig.Caches) == 1 && data.CacheMemoryConfig.Caches[0].ID == "default" - - for _, cache := range data.CacheMemoryConfig.Caches { - // Skip restore-only caches - if cache.RestoreOnly { - continue - } - - // Default cache uses /tmp/gh-aw/cache-memory/ for backward compatibility - // Other caches use /tmp/gh-aw/cache-memory-{id}/ to prevent overlaps - var cacheDir string - if cache.ID == "default" { - cacheDir = "/tmp/gh-aw/cache-memory" - } else { - cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s", cache.ID) - } - - // Add upload-artifact step for each cache (runs always) - if useBackwardCompatiblePaths { - builder.WriteString(" - name: Upload cache-memory data as artifact\n") - } else { - fmt.Fprintf(builder, " - name: Upload cache-memory data as artifact (%s)\n", cache.ID) - } - fmt.Fprintf(builder, " uses: %s\n", GetActionPin("actions/upload-artifact")) - builder.WriteString(" if: always()\n") - builder.WriteString(" with:\n") - // Always use the new artifact name and path format - if useBackwardCompatiblePaths { - builder.WriteString(" name: cache-memory\n") - } else { - fmt.Fprintf(builder, " name: cache-memory-%s\n", cache.ID) - } - fmt.Fprintf(builder, " path: %s\n", cacheDir) - // Add retention-days if configured - if cache.RetentionDays != nil { - fmt.Fprintf(builder, " retention-days: %d\n", *cache.RetentionDays) - } - } -} - -// buildCacheMemoryPromptSection builds a PromptSection for cache memory instructions -// Returns a PromptSection that references a template file with substitutions, or nil if no cache is configured -func buildCacheMemoryPromptSection(config *CacheMemoryConfig) *PromptSection { - if config == nil || len(config.Caches) == 0 { - return nil - } - - // Check if there's only one cache with ID "default" to use singular template - if len(config.Caches) == 1 && config.Caches[0].ID == "default" { - cache := config.Caches[0] - cacheDir := "/tmp/gh-aw/cache-memory/" - - // Build description text - descriptionText := "" - if cache.Description != "" { - descriptionText = " " + cache.Description - } - - cacheLog.Printf("Building cache memory prompt section with env vars: cache_dir=%s, description=%s", cacheDir, descriptionText) - - // Return prompt section with template file and environment variables for substitution - return &PromptSection{ - Content: cacheMemoryPromptFile, - IsFile: true, - EnvVars: map[string]string{ - "GH_AW_CACHE_DIR": cacheDir, - "GH_AW_CACHE_DESCRIPTION": descriptionText, - }, - } - } - - // Multiple caches or non-default single cache - generate content inline - var content strings.Builder - content.WriteString("\n") - content.WriteString("---\n") - content.WriteString("\n") - content.WriteString("## Cache Folders Available\n") - content.WriteString("\n") - content.WriteString("You have access to persistent cache folders where you can read and write files to create memories and store information:\n") - content.WriteString("\n") - - // List all caches - for _, cache := range config.Caches { - var cacheDir string - if cache.ID == "default" { - cacheDir = "/tmp/gh-aw/cache-memory/" - } else { - cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s/", cache.ID) - } - if cache.Description != "" { - fmt.Fprintf(&content, "- **%s**: `%s` - %s\n", cache.ID, cacheDir, cache.Description) - } else { - fmt.Fprintf(&content, "- **%s**: `%s`\n", cache.ID, cacheDir) - } - } - - content.WriteString("\n") - content.WriteString("- **Read/Write Access**: You can freely read from and write to any files in these folders\n") - content.WriteString("- **Persistence**: Files in these folders persist across workflow runs via GitHub Actions cache\n") - content.WriteString("- **Last Write Wins**: If multiple processes write to the same file, the last write will be preserved\n") - content.WriteString("- **File Share**: Use these as simple file shares - organize files as you see fit\n") - content.WriteString("\n") - content.WriteString("Examples of what you can store:\n") - - // Add examples for each cache - for _, cache := range config.Caches { - var cacheDir string - if cache.ID == "default" { - cacheDir = "/tmp/gh-aw/cache-memory" - } else { - cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s", cache.ID) - } - fmt.Fprintf(&content, "- `%s/notes.txt` - general notes and observations\n", cacheDir) - fmt.Fprintf(&content, "- `%s/preferences.json` - user preferences and settings\n", cacheDir) - fmt.Fprintf(&content, "- `%s/state/` - organized state files in subdirectories\n", cacheDir) - } - - content.WriteString("\n") - content.WriteString("Feel free to create, read, update, and organize files in these folders as needed for your tasks.\n") - - return &PromptSection{ - Content: content.String(), - IsFile: false, - } -} - -// buildUpdateCacheMemoryJob builds a job that updates cache-memory after detection passes -// This job downloads cache-memory artifacts and saves them to GitHub Actions cache -func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetectionEnabled bool) (*Job, error) { - if data.CacheMemoryConfig == nil || len(data.CacheMemoryConfig.Caches) == 0 { - return nil, nil - } - - // Only create this job if threat detection is enabled - // Otherwise, cache is updated automatically by actions/cache post-action - if !threatDetectionEnabled { - return nil, nil - } - - cacheLog.Printf("Building update_cache_memory job for %d caches (threatDetectionEnabled=%v)", len(data.CacheMemoryConfig.Caches), threatDetectionEnabled) - - var steps []string - - // Build steps for each cache - for _, cache := range data.CacheMemoryConfig.Caches { - // Skip restore-only caches - if cache.RestoreOnly { - continue - } - - // Determine artifact name and cache directory - var artifactName, cacheDir string - if cache.ID == "default" { - artifactName = "cache-memory" - cacheDir = "/tmp/gh-aw/cache-memory" - } else { - artifactName = fmt.Sprintf("cache-memory-%s", cache.ID) - cacheDir = fmt.Sprintf("/tmp/gh-aw/cache-memory-%s", cache.ID) - } - - // Download artifact step - var downloadStep strings.Builder - fmt.Fprintf(&downloadStep, " - name: Download cache-memory artifact (%s)\n", cache.ID) - fmt.Fprintf(&downloadStep, " uses: %s\n", GetActionPin("actions/download-artifact")) - downloadStep.WriteString(" continue-on-error: true\n") - downloadStep.WriteString(" with:\n") - fmt.Fprintf(&downloadStep, " name: %s\n", artifactName) - fmt.Fprintf(&downloadStep, " path: %s\n", cacheDir) - steps = append(steps, downloadStep.String()) - - // Generate cache key (same logic as in generateCacheMemorySteps) - cacheKey := cache.Key - if cacheKey == "" { - if cache.ID == "default" { - cacheKey = "memory-${{ github.workflow }}-${{ github.run_id }}" - } else { - cacheKey = fmt.Sprintf("memory-%s-${{ github.workflow }}-${{ github.run_id }}", cache.ID) - } - } - - // Automatically append -${{ github.run_id }} if the key doesn't already end with it - runIdSuffix := "-${{ github.run_id }}" - if !strings.HasSuffix(cacheKey, runIdSuffix) { - cacheKey = cacheKey + runIdSuffix - } - - // Save to cache step - var saveStep strings.Builder - fmt.Fprintf(&saveStep, " - name: Save cache-memory to cache (%s)\n", cache.ID) - fmt.Fprintf(&saveStep, " uses: %s\n", GetActionPin("actions/cache/save")) - saveStep.WriteString(" with:\n") - fmt.Fprintf(&saveStep, " key: %s\n", cacheKey) - fmt.Fprintf(&saveStep, " path: %s\n", cacheDir) - steps = append(steps, saveStep.String()) - } - - // If no writable caches, return nil - if len(steps) == 0 { - return nil, nil - } - - // Add setup step to copy scripts at the beginning - var setupSteps []string - setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" || c.actionMode.IsScript() { - // For dev mode (local action path), checkout the actions folder first - setupSteps = append(setupSteps, c.generateCheckoutActionsFolder(data)...) - - // Cache restore job doesn't need project support - setupSteps = append(setupSteps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) - } - - // Prepend setup steps to all cache steps - steps = append(setupSteps, steps...) - - // Job condition: only run if detection passed - jobCondition := "always() && needs.detection.outputs.success == 'true'" - - // Set up permissions for the cache update job - // If using local actions (dev mode without action-tag), we need contents: read to checkout the actions folder - permissions := NewPermissionsEmpty().RenderToYAML() // Default: no special permissions needed - if setupActionRef != "" && len(c.generateCheckoutActionsFolder(data)) > 0 { - // Need contents: read to checkout the actions folder - perms := NewPermissionsContentsRead() - permissions = perms.RenderToYAML() - } - - job := &Job{ - Name: "update_cache_memory", - DisplayName: "", // No display name - job ID is sufficient - RunsOn: "runs-on: ubuntu-latest", - If: jobCondition, - Permissions: permissions, - Needs: []string{"agent", "detection"}, - Steps: steps, - } - - return job, nil -} From 6d857ccca891db90a33c92e8ffbf098583fa4a91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:35:21 +0000 Subject: [PATCH 10/11] Changes before error encountered Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/cache.go | 19 +++++++++++++------ .../cache_memory_restore_keys_test.go | 10 ++++++---- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 8b93231c8a6..d70803547b3 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -396,8 +396,8 @@ func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { } // Generate restore keys based on scope - // - "workflow" (default): Removes only run_id suffix, keeping workflow identifier - // - "repo": Removes only run_id suffix, allowing repo-wide cache sharing + // - "workflow" (default): Single restore key with workflow ID (secure) + // - "repo": Two restore keys - with and without workflow ID (allows cross-workflow sharing) var restoreKeys []string // Determine scope (default to "workflow" for safety) @@ -406,7 +406,7 @@ func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { scope = "workflow" } - // For both scopes, remove the run_id suffix as a single unit (don't split the key) + // First restore key: remove the run_id suffix as a single unit (don't split the key) // The cacheKey always ends with "-${{ github.run_id }}" (ensured by code above) if strings.HasSuffix(cacheKey, runIdSuffix) { // Remove the run_id suffix to create the restore key @@ -422,9 +422,16 @@ func generateCacheMemorySteps(builder *strings.Builder, data *WorkflowData) { } } - // Note: The difference between "workflow" and "repo" scope is semantic - // Both generate the same restore key pattern, but "repo" scope indicates - // the user's intent to share caches across workflows (and is rejected in strict mode) + // For repo scope, add an additional restore key without the workflow ID + // This allows cache sharing across all workflows in the repository + if scope == "repo" { + // Remove both workflow and run_id to create a repo-wide restore key + // For example: "memory-chroma-${{ github.workflow }}-${{ github.run_id }}" -> "memory-chroma-" + repoKey := strings.TrimSuffix(cacheKey, "${{ github.workflow }}-${{ github.run_id }}") + if repoKey != cacheKey && repoKey != "" { + restoreKeys = append(restoreKeys, repoKey) + } + } // Step name and action // Use actions/cache/restore for restore-only caches or when threat detection is enabled diff --git a/pkg/workflow/cache_memory_restore_keys_test.go b/pkg/workflow/cache_memory_restore_keys_test.go index 9365aee6dbb..28c5f8763ba 100644 --- a/pkg/workflow/cache_memory_restore_keys_test.go +++ b/pkg/workflow/cache_memory_restore_keys_test.go @@ -151,7 +151,7 @@ safe-outputs: genericFallbacks: []string{"memory-"}, }, { - name: "cache-memory with repo scope should have same restore key as workflow scope", + name: "cache-memory with repo scope should have two restore keys", frontmatter: `--- name: Test Cache Memory Repo Scope on: workflow_dispatch @@ -170,12 +170,14 @@ tools: allowed: [get_repository] ---`, expectedInLock: []string{ - // Repo scope now generates the same single restore key as workflow scope - // The difference is semantic (intent) and enforced in strict mode + // Repo scope generates two restore keys: + // 1. With workflow ID (try same workflow first) + // 2. Without workflow ID (allows cross-workflow sharing) "restore-keys: |", "shared-cache-${{ github.workflow }}-", + "shared-cache-", }, - genericFallbacks: []string{"shared-cache-", "shared-"}, // Should NOT have these fallbacks + genericFallbacks: []string{}, // No check - repo scope intentionally allows generic restore key }, } From 0365afa60866d6e7f135ea7d7fa44c87f8dadcff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:15:18 +0000 Subject: [PATCH 11/11] Fix action pins test count and complete implementation - Updated TestGetActionPinsSorting to expect 39 pins (was 38) - All cache-memory scope functionality implemented: - workflow scope: single restore key (secure default) - repo scope: two restore keys (workflow-level + repo-wide) - strict mode: rejects repo scope - All tests passing - Ready for final review Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/action_pins_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/action_pins_test.go b/pkg/workflow/action_pins_test.go index 9cbe8948a94..b7bfe74af25 100644 --- a/pkg/workflow/action_pins_test.go +++ b/pkg/workflow/action_pins_test.go @@ -297,9 +297,9 @@ func TestApplyActionPinToStep(t *testing.T) { func TestGetActionPinsSorting(t *testing.T) { pins := getActionPins() - // Verify we got all the pins (38 as of February 2026) - if len(pins) != 38 { - t.Errorf("getActionPins() returned %d pins, expected 38", len(pins)) + // Verify we got all the pins (39 as of February 2026) + if len(pins) != 39 { + t.Errorf("getActionPins() returned %d pins, expected 39", len(pins)) } // Verify they are sorted by version (descending) then by repository name (ascending)