From f97a7e7f86c7423e08706a6aba9dcf16989cf902 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 04:57:05 +0000 Subject: [PATCH 1/4] Initial plan From bd0cdbcaebea7ffc11d00a9f77737ca7d2a79cfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 05:36:15 +0000 Subject: [PATCH 2/4] feat: resolve import-inputs expressions in imports section before nested import discovery Allow ${{ github.aw.import-inputs.* }} expressions in the imports: section of shared workflows by applying expression substitution before parsing the frontmatter for nested import discovery in the BFS traversal. Previously, substituteImportInputsInContent was only called inside extractAllImportFields (after nested imports had already been discovered), so with: values containing ${{ github.aw.import-inputs.* }} were passed as literal strings to nested imports. The fix applies the substitution earlier, before ExtractFrontmatterFromContent is called for nested import discovery, so that forwarded inputs are correctly resolved. Adds TestImportInputsForwardedToNestedImports to verify the fix. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/50450d68-2884-4f6d-91d1-7b2afe54671c Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/import_bfs.go | 12 +++- pkg/workflow/imports_inputs_test.go | 101 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/pkg/parser/import_bfs.go b/pkg/parser/import_bfs.go index 9bb09aebabc..61f8d2ed20c 100644 --- a/pkg/parser/import_bfs.go +++ b/pkg/parser/import_bfs.go @@ -293,13 +293,23 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a return nil, fmt.Errorf("failed to read imported file '%s': %w", item.fullPath, err) } + // When the import provides 'with' inputs, apply expression substitution to the + // raw content before parsing frontmatter for nested imports discovery. This + // resolves ${{ github.aw.import-inputs.* }} expressions that appear in the + // 'with' values of nested imports, enabling multi-level workflow composition. + contentForFrontmatter := string(content) + if len(item.inputs) > 0 { + inputsWithDefaults := applyImportSchemaDefaults(contentForFrontmatter, item.inputs) + contentForFrontmatter = substituteImportInputsInContent(contentForFrontmatter, inputsWithDefaults) + } + // Extract frontmatter from imported file to discover nested imports. // Use the process-level cache for builtin virtual files to avoid repeated YAML parsing. var result *FrontmatterResult if strings.HasPrefix(item.fullPath, BuiltinPathPrefix) { result, err = ExtractFrontmatterFromBuiltinFile(item.fullPath, content) } else { - result, err = ExtractFrontmatterFromContent(string(content)) + result, err = ExtractFrontmatterFromContent(contentForFrontmatter) } if err != nil { // If frontmatter extraction fails, continue with other processing diff --git a/pkg/workflow/imports_inputs_test.go b/pkg/workflow/imports_inputs_test.go index 7520fcbde82..90907f49467 100644 --- a/pkg/workflow/imports_inputs_test.go +++ b/pkg/workflow/imports_inputs_test.go @@ -104,6 +104,107 @@ This workflow tests import with inputs. } } +// TestImportInputsForwardedToNestedImports tests that ${{ github.aw.import-inputs.* }} +// expressions in the imports: section of a shared workflow are resolved before nested +// imports are processed. This enables multi-level workflow composition where a shared +// workflow can forward its own inputs to the workflows it depends on. +func TestImportInputsForwardedToNestedImports(t *testing.T) { + tempDir := testutil.TempDir(t, "test-import-inputs-forwarding-*") + + sharedDir := filepath.Join(tempDir, "shared") + if err := os.MkdirAll(sharedDir, 0755); err != nil { + t.Fatalf("Failed to create shared directory: %v", err) + } + + // Create the leaf shared workflow that accepts a branch-name input + leafPath := filepath.Join(sharedDir, "repo-memory.md") + leafContent := `--- +import-schema: + branch-name: + type: string + required: true + description: "Branch name for storage" +tools: + bash: + - "git *" +--- + +Store data in branch ${{ github.aw.import-inputs.branch-name }}. +` + if err := os.WriteFile(leafPath, []byte(leafContent), 0644); err != nil { + t.Fatalf("Failed to write leaf file: %v", err) + } + + // Create the intermediate shared workflow that accepts branch-name and forwards it + // to the leaf workflow via an expression in its own imports: section + intermediatePath := filepath.Join(sharedDir, "daily-report.md") + intermediateContent := `--- +import-schema: + branch-name: + type: string + required: true + description: "Branch name for repo-memory storage" + +imports: + - uses: shared/repo-memory.md + with: + branch-name: ${{ github.aw.import-inputs.branch-name }} +--- + +Daily report workflow. +` + if err := os.WriteFile(intermediatePath, []byte(intermediateContent), 0644); err != nil { + t.Fatalf("Failed to write intermediate file: %v", err) + } + + // Create the consuming workflow that imports the intermediate workflow with a concrete value + workflowPath := filepath.Join(tempDir, "consumer.md") + workflowContent := `--- +on: issues +permissions: + contents: read + issues: read +engine: copilot +imports: + - uses: shared/daily-report.md + with: + branch-name: "memory/my-workflow" +--- + +Consumer workflow. +` + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write consumer workflow: %v", err) + } + + compiler := workflow.NewCompiler() + if err := compiler.CompileWorkflow(workflowPath); err != nil { + t.Fatalf("CompileWorkflow failed: %v", err) + } + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockFileContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContent := string(lockFileContent) + + // The leaf workflow's tools (bash) should be merged + if !strings.Contains(lockContent, "git *") { + t.Error("Expected lock file to contain bash tool from leaf workflow (git *)") + } + + // The substituted branch-name value should appear in the compiled output + if !strings.Contains(lockContent, "memory/my-workflow") { + t.Error("Expected compiled workflow to contain forwarded branch-name value 'memory/my-workflow'") + } + + // No unresolved import-inputs expressions should remain + if strings.Contains(lockContent, "github.aw.import-inputs.branch-name") { + t.Error("Generated workflow should not contain unsubstituted github.aw.import-inputs.branch-name expression") + } +} + // TestImportWithInputsStringFormat tests that string import format still works func TestImportWithInputsStringFormat(t *testing.T) { // Create a temporary directory for test files From 21f82e17bd56167df9b54ae5800daa43585cc1c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:09:15 +0000 Subject: [PATCH 3/4] fix: avoid double YAML parse in BFS; add parser integration tests for import-inputs forwarding - Refactor applyImportSchemaDefaults to delegate to a new applyImportSchemaDefaultsFromFrontmatter helper that works with an already-parsed frontmatter map, avoiding a redundant YAML parse. - In the BFS loop, parse the original content once, then reuse that result to compute import-schema defaults before substitution (instead of calling applyImportSchemaDefaults which would parse again). Only the second parse (of substituted content) is necessary to see resolved nested imports. - Add two integration tests in pkg/parser testing forwarding of single and multiple inputs through an intermediate shared workflow. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2f21b3cc-a5e9-4e11-b5e7-21d18f0b3c87 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/import_bfs.go | 30 ++- pkg/parser/import_field_extractor.go | 11 +- pkg/parser/import_inputs_integration_test.go | 224 +++++++++++++++++++ 3 files changed, 252 insertions(+), 13 deletions(-) create mode 100644 pkg/parser/import_inputs_integration_test.go diff --git a/pkg/parser/import_bfs.go b/pkg/parser/import_bfs.go index 61f8d2ed20c..b2dc7373870 100644 --- a/pkg/parser/import_bfs.go +++ b/pkg/parser/import_bfs.go @@ -293,23 +293,29 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a return nil, fmt.Errorf("failed to read imported file '%s': %w", item.fullPath, err) } - // When the import provides 'with' inputs, apply expression substitution to the - // raw content before parsing frontmatter for nested imports discovery. This - // resolves ${{ github.aw.import-inputs.* }} expressions that appear in the - // 'with' values of nested imports, enabling multi-level workflow composition. - contentForFrontmatter := string(content) - if len(item.inputs) > 0 { - inputsWithDefaults := applyImportSchemaDefaults(contentForFrontmatter, item.inputs) - contentForFrontmatter = substituteImportInputsInContent(contentForFrontmatter, inputsWithDefaults) - } - - // Extract frontmatter from imported file to discover nested imports. + // Extract frontmatter from the imported file's original content. // Use the process-level cache for builtin virtual files to avoid repeated YAML parsing. var result *FrontmatterResult if strings.HasPrefix(item.fullPath, BuiltinPathPrefix) { result, err = ExtractFrontmatterFromBuiltinFile(item.fullPath, content) } else { - result, err = ExtractFrontmatterFromContent(contentForFrontmatter) + result, err = ExtractFrontmatterFromContent(string(content)) + } + + // When the import provides 'with' inputs, apply expression substitution before + // discovering nested imports. This resolves ${{ github.aw.import-inputs.* }} + // expressions that appear in the 'with' values of nested imports, enabling + // multi-level workflow composition. + // We reuse the already-parsed frontmatter to extract import-schema defaults, + // avoiding a second YAML parse inside applyImportSchemaDefaults. + if err == nil && result != nil && len(item.inputs) > 0 { + inputsWithDefaults := applyImportSchemaDefaultsFromFrontmatter(result.Frontmatter, item.inputs) + substituted := substituteImportInputsInContent(string(content), inputsWithDefaults) + // Re-parse the substituted content so that nested-import discovery sees + // the resolved 'with' values instead of literal expression strings. + if reparse, rerr := ExtractFrontmatterFromContent(substituted); rerr == nil { + result = reparse + } } if err != nil { // If frontmatter extraction fails, continue with other processing diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index b051e0a6984..b34f4ef16d2 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -653,7 +653,16 @@ func applyImportSchemaDefaults(rawContent string, inputs map[string]any) map[str if err != nil { return inputs } - rawSchema, ok := parsed.Frontmatter["import-schema"] + return applyImportSchemaDefaultsFromFrontmatter(parsed.Frontmatter, inputs) +} + +// applyImportSchemaDefaultsFromFrontmatter applies import-schema defaults from an +// already-parsed frontmatter map, avoiding a redundant YAML parse when the caller +// has already extracted the frontmatter. Returns a copy of inputs augmented with +// default values for any schema parameters declared with a "default" field but not +// present in the provided inputs map. Parameters already in inputs are left unchanged. +func applyImportSchemaDefaultsFromFrontmatter(frontmatter map[string]any, inputs map[string]any) map[string]any { + rawSchema, ok := frontmatter["import-schema"] if !ok { return inputs } diff --git a/pkg/parser/import_inputs_integration_test.go b/pkg/parser/import_inputs_integration_test.go new file mode 100644 index 00000000000..2dea92d19ad --- /dev/null +++ b/pkg/parser/import_inputs_integration_test.go @@ -0,0 +1,224 @@ +//go:build integration + +package parser_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/testutil" +) + +// TestImportInputsForwardedToNestedImports_Integration verifies via the parser package +// API that ${{ github.aw.import-inputs.* }} expressions inside an imported workflow's +// imports: frontmatter section are resolved before nested import discovery, enabling +// multi-level shared-workflow composition. +func TestImportInputsForwardedToNestedImports_Integration(t *testing.T) { + tempDir := testutil.TempDir(t, "import-inputs-forwarding-*") + + sharedDir := filepath.Join(tempDir, "shared") + if err := os.MkdirAll(sharedDir, 0755); err != nil { + t.Fatalf("Failed to create shared directory: %v", err) + } + + // Leaf shared workflow — accepts branch-name via import-schema + leafPath := filepath.Join(sharedDir, "repo-memory.md") + leafContent := `--- +import-schema: + branch-name: + type: string + required: true + description: "Branch name for storage" +tools: + bash: + - "git *" +--- + +Store data in branch ${{ github.aw.import-inputs.branch-name }}. +` + if err := os.WriteFile(leafPath, []byte(leafContent), 0644); err != nil { + t.Fatalf("Failed to write leaf workflow: %v", err) + } + + // Intermediate shared workflow — accepts branch-name and forwards it to the leaf + // via an expression in its own imports: section + intermediateContent := `--- +import-schema: + branch-name: + type: string + required: true + description: "Branch name for repo-memory storage" + +imports: + - uses: shared/repo-memory.md + with: + branch-name: ${{ github.aw.import-inputs.branch-name }} +--- + +Daily report workflow. +` + intermediatePath := filepath.Join(sharedDir, "daily-report.md") + if err := os.WriteFile(intermediatePath, []byte(intermediateContent), 0644); err != nil { + t.Fatalf("Failed to write intermediate workflow: %v", err) + } + + // Consumer workflow — imports the intermediate with a concrete value + consumerContent := `--- +on: issues +permissions: + contents: read + issues: read +engine: copilot +imports: + - uses: shared/daily-report.md + with: + branch-name: "memory/my-workflow" +--- + +Consumer workflow. +` + consumerPath := filepath.Join(tempDir, "consumer.md") + if err := os.WriteFile(consumerPath, []byte(consumerContent), 0644); err != nil { + t.Fatalf("Failed to write consumer workflow: %v", err) + } + + // Parse the consumer workflow's frontmatter and process its imports + result, err := parser.ExtractFrontmatterFromContent(consumerContent) + if err != nil { + t.Fatalf("Failed to extract frontmatter: %v", err) + } + + importsResult, err := parser.ProcessImportsFromFrontmatterWithSource( + result.Frontmatter, + tempDir, + nil, + consumerPath, + consumerContent, + ) + if err != nil { + t.Fatalf("ProcessImportsFromFrontmatterWithSource failed: %v", err) + } + + // The leaf workflow's bash tool (git *) should be present in merged tools + if !strings.Contains(importsResult.MergedTools, "git *") { + t.Errorf("MergedTools should contain 'git *' from leaf workflow; got:\n%s", importsResult.MergedTools) + } + + // No unresolved import-inputs expressions should remain anywhere + mergedContent := importsResult.MergedTools + importsResult.MergedMarkdown + if strings.Contains(mergedContent, "github.aw.import-inputs") { + t.Errorf("Merged content should not contain unsubstituted github.aw.import-inputs expressions;\ngot:\n%s", mergedContent) + } +} + +// TestImportInputsMultipleForwardedToNestedImports_Integration verifies that multiple +// ${{ github.aw.import-inputs.* }} expressions in an intermediate workflow's imports: +// section are all resolved before nested import discovery. +func TestImportInputsMultipleForwardedToNestedImports_Integration(t *testing.T) { + tempDir := testutil.TempDir(t, "import-inputs-multi-forwarding-*") + + sharedDir := filepath.Join(tempDir, "shared") + if err := os.MkdirAll(sharedDir, 0755); err != nil { + t.Fatalf("Failed to create shared directory: %v", err) + } + + // Leaf shared workflow accepting two inputs + leafPath := filepath.Join(sharedDir, "publisher.md") + leafContent := `--- +import-schema: + target-repo: + type: string + required: true + description: "Target repository" + title-prefix: + type: string + required: true + description: "Title prefix" +tools: + bash: + - "curl *" +--- + +Publish to ${{ github.aw.import-inputs.target-repo }} with prefix ${{ github.aw.import-inputs.title-prefix }}. +` + if err := os.WriteFile(leafPath, []byte(leafContent), 0644); err != nil { + t.Fatalf("Failed to write leaf workflow: %v", err) + } + + // Intermediate workflow — accepts both inputs and forwards them to the leaf + intermediateContent := `--- +import-schema: + target-repo: + type: string + required: true + description: "Target repository for publishing" + title-prefix: + type: string + required: true + description: "Title prefix for created items" + +imports: + - uses: shared/publisher.md + with: + target-repo: ${{ github.aw.import-inputs.target-repo }} + title-prefix: ${{ github.aw.import-inputs.title-prefix }} +--- + +Intermediate reporter. +` + intermediatePath := filepath.Join(sharedDir, "reporter.md") + if err := os.WriteFile(intermediatePath, []byte(intermediateContent), 0644); err != nil { + t.Fatalf("Failed to write intermediate workflow: %v", err) + } + + // Consumer that provides concrete values for both inputs + consumerContent := `--- +on: issues +permissions: + contents: read +engine: copilot +imports: + - uses: shared/reporter.md + with: + target-repo: "myorg/myrepo" + title-prefix: "daily-" +--- + +Consumer. +` + consumerPath := filepath.Join(tempDir, "consumer.md") + if err := os.WriteFile(consumerPath, []byte(consumerContent), 0644); err != nil { + t.Fatalf("Failed to write consumer workflow: %v", err) + } + + result, err := parser.ExtractFrontmatterFromContent(consumerContent) + if err != nil { + t.Fatalf("Failed to extract frontmatter: %v", err) + } + + importsResult, err := parser.ProcessImportsFromFrontmatterWithSource( + result.Frontmatter, + tempDir, + nil, + consumerPath, + consumerContent, + ) + if err != nil { + t.Fatalf("ProcessImportsFromFrontmatterWithSource failed: %v", err) + } + + // The leaf workflow's bash tool (curl *) must be present — proving the leaf + // was discovered and merged after both inputs were forwarded correctly. + if !strings.Contains(importsResult.MergedTools, "curl *") { + t.Errorf("MergedTools should contain 'curl *' from leaf workflow; got:\n%s", importsResult.MergedTools) + } + + // No unresolved import-inputs expressions should remain anywhere + mergedContent := importsResult.MergedTools + importsResult.MergedMarkdown + if strings.Contains(mergedContent, "github.aw.import-inputs") { + t.Errorf("Merged content should not contain unsubstituted github.aw.import-inputs expressions;\ngot:\n%s", mergedContent) + } +} From a2dddf6a5f5c71eeb1444d19808bd95e6f53910d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:23:52 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20merge=20main=20and=20update=20golden?= =?UTF-8?q?=20files=20for=20Copilot=20CLI=20version=20bump=20(1.0.20=20?= =?UTF-8?q?=E2=86=92=201.0.21)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/github/gh-aw/sessions/6324e02e-a20b-442a-9182-5475820588da Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../TestWasmGolden_CompileFixtures/basic-copilot.golden | 6 +++--- .../TestWasmGolden_CompileFixtures/with-imports.golden | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden index ad95d2ef530..298f1b50644 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden @@ -51,8 +51,8 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.20" - GH_AW_INFO_AGENT_VERSION: "1.0.20" + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" GH_AW_INFO_WORKFLOW_NAME: "basic-copilot-test" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -289,7 +289,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh 1.0.20 + run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh 1.0.21 env: GH_HOST: github.com - name: Install AWF binary diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden index c531714871c..b6a9a5e3aae 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden @@ -51,8 +51,8 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.20" - GH_AW_INFO_AGENT_VERSION: "1.0.20" + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" GH_AW_INFO_WORKFLOW_NAME: "with-imports-test" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -290,7 +290,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh 1.0.20 + run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh 1.0.21 env: GH_HOST: github.com - name: Install AWF binary