diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d908d2946dd..1d930765b5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2587,6 +2587,170 @@ jobs: echo "- Success count: ${{ steps.add-workflows.outputs.success_count }}" echo "- Failure count: ${{ steps.add-workflows.outputs.failure_count }}" + integration-update: + name: Integration Update - Preserve Local Imports + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + concurrency: + group: ci-${{ github.ref }}-integration-update + cancel-in-progress: true + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + id: setup-go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 + with: + go-version-file: go.mod + cache: true + + - name: Report Go cache status + run: | + if [ "${{ steps.setup-go.outputs.cache-hit }}" == "true" ]; then + echo "✅ Go cache hit" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ Go cache miss" >> $GITHUB_STEP_SUMMARY + fi + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Build gh-aw binary + run: make build + + - name: Set up isolated test workspace + run: | + mkdir -p /tmp/test-update-workspace/.github/workflows + cd /tmp/test-update-workspace + git init -q + git config user.email "test@example.com" + git config user.name "Test" + echo "✅ Test workspace initialised at /tmp/test-update-workspace" + + - name: Add a workflow from githubnext/agentics + id: add-workflow + env: + GH_TOKEN: ${{ github.token }} + run: | + cd /tmp/test-update-workspace + + echo "## Integration Update Test: Preserve Local Imports" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Add daily-team-status which uses shared imports in githubnext/agentics + if /home/runner/work/gh-aw/gh-aw/gh-aw add githubnext/agentics/workflows/daily-team-status.md --force 2>&1; then + echo "✅ Added workflow successfully" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Failed to add workflow" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + WORKFLOW=".github/workflows/daily-team-status.md" + if [ ! -f "$WORKFLOW" ]; then + echo "❌ Workflow file not found after add" + exit 1 + fi + + # Check if the workflow has relative imports + if grep -q "^imports:" "$WORKFLOW"; then + echo "✅ Workflow has an imports field" >> $GITHUB_STEP_SUMMARY + # Collect relative import paths (those without "@" — not yet cross-repo refs). + # Python is used for robust YAML-aware parsing instead of fragile AWK patterns. + RELATIVE_IMPORTS=$(python3 - "$WORKFLOW" <<'PYEOF' +import sys, re + +content = open(sys.argv[1]).read() +# Find the imports: block (list items under the key, indented with 2+ spaces) +m = re.search(r'^imports:\n((?:[ \t]+-[ \t]+.+\n)+)', content, re.MULTILINE) +if m: + for line in m.group(1).splitlines(): + val = line.strip().lstrip('- ').strip() + if val and '@' not in val: + print(val) +PYEOF + ) + if [ -n "$RELATIVE_IMPORTS" ]; then + echo "has_relative_imports=true" >> $GITHUB_OUTPUT + echo "$RELATIVE_IMPORTS" > /tmp/relative-imports.txt + echo "Relative import paths: $RELATIVE_IMPORTS" >> $GITHUB_STEP_SUMMARY + else + echo "has_relative_imports=false" >> $GITHUB_OUTPUT + echo "⚠️ All imports already use cross-repo refs; skipping local-file preservation check" >> $GITHUB_STEP_SUMMARY + fi + else + echo "has_relative_imports=false" >> $GITHUB_OUTPUT + echo "⚠️ No imports field found in added workflow; skipping preservation check" >> $GITHUB_STEP_SUMMARY + fi + + - name: Create local copies of the shared import files + if: steps.add-workflow.outputs.has_relative_imports == 'true' + run: | + cd /tmp/test-update-workspace + echo "Creating local shared files..." >> $GITHUB_STEP_SUMMARY + while IFS= read -r import_path; do + local_file=".github/workflows/$import_path" + mkdir -p "$(dirname "$local_file")" + echo "# Local shared file (simulating user-copied content)" > "$local_file" + echo "✅ Created: $local_file" >> $GITHUB_STEP_SUMMARY + done < /tmp/relative-imports.txt + + - name: Run gh aw update --force + if: steps.add-workflow.outputs.has_relative_imports == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + cd /tmp/test-update-workspace + echo "Running gh aw update --force --no-compile daily-team-status ..." + /home/runner/work/gh-aw/gh-aw/gh-aw update daily-team-status --force --no-compile 2>&1 + echo "✅ Update completed" >> $GITHUB_STEP_SUMMARY + + - name: Verify local import paths are preserved after update + if: steps.add-workflow.outputs.has_relative_imports == 'true' + run: | + cd /tmp/test-update-workspace + WORKFLOW=".github/workflows/daily-team-status.md" + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Import paths after \`gh aw update\`" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + grep -A10 "^imports:" "$WORKFLOW" >> $GITHUB_STEP_SUMMARY || true + echo '```' >> $GITHUB_STEP_SUMMARY + + FAILED=false + while IFS= read -r import_path; do + local_file=".github/workflows/$import_path" + + # Confirm the local file still exists (sanity check) + if [ ! -f "$local_file" ]; then + echo "⚠️ Local file not found (was it deleted?): $local_file" + continue + fi + + # The import entry in the workflow file must still be the relative path, + # NOT a cross-repo reference (which would contain "@" and "owner/repo/"). + if grep -qF "- $import_path" "$WORKFLOW"; then + echo "✅ Relative import preserved: $import_path" >> $GITHUB_STEP_SUMMARY + else + echo "❌ FAIL: '$import_path' was rewritten even though the local file exists" >> $GITHUB_STEP_SUMMARY + echo "❌ Current imports section:" + grep -A10 "^imports:" "$WORKFLOW" || true + FAILED=true + fi + done < /tmp/relative-imports.txt + + if [ "$FAILED" = "true" ]; then + echo "❌ **FAILURE**: gh aw update rewrote local relative imports to cross-repo paths" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + echo "✅ **SUCCESS**: All local relative import paths were preserved by gh aw update" >> $GITHUB_STEP_SUMMARY + integration-unauthenticated-add: name: Integration Unauthenticated Add (Public Repo) runs-on: ubuntu-latest diff --git a/pkg/cli/imports.go b/pkg/cli/imports.go index 3e3ebba9c64..c96df5fc9f9 100644 --- a/pkg/cli/imports.go +++ b/pkg/cli/imports.go @@ -58,8 +58,10 @@ func resolveImportPath(importPath string, workflowPath string) string { // processImportsWithWorkflowSpec processes imports field in frontmatter and replaces local file references // with workflowspec format (owner/repo/path@sha) for all imports found. // Handles both array form and object form (with 'aw' subfield) of the imports field. -func processImportsWithWorkflowSpec(content string, workflow *WorkflowSpec, commitSHA string, verbose bool) (string, error) { - importsLog.Printf("Processing imports with workflowspec: repo=%s, sha=%s", workflow.RepoSlug, commitSHA) +// If localWorkflowDir is non-empty, any import path whose file exists under that directory is +// left as a local relative path rather than being rewritten to a cross-repo reference. +func processImportsWithWorkflowSpec(content string, workflow *WorkflowSpec, commitSHA string, localWorkflowDir string, verbose bool) (string, error) { + importsLog.Printf("Processing imports with workflowspec: repo=%s, sha=%s, localWorkflowDir=%s", workflow.RepoSlug, commitSHA, localWorkflowDir) if verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Processing imports field to replace with workflowspec")) } @@ -79,6 +81,10 @@ func processImportsWithWorkflowSpec(content string, workflow *WorkflowSpec, comm } // processImportPaths converts a list of raw import paths to workflowspec format. + // Paths that already use the workflowspec format (contain "@") are left unchanged. + // When localWorkflowDir is set, relative paths whose files exist locally are also + // preserved as-is so that consumers who have copied shared files into their own repo + // are not forced onto cross-repo references after every `gh aw update`. processImportPaths := func(imports []string) []string { processed := make([]string, 0, len(imports)) for _, importPath := range imports { @@ -87,6 +93,16 @@ func processImportsWithWorkflowSpec(content string, workflow *WorkflowSpec, comm processed = append(processed, importPath) continue } + // Preserve relative paths whose files exist in the local workflow directory. + // Absolute paths (starting with "/") are not checked — they are always resolved + // relative to the repo root and cannot be reliably tested here. + if localWorkflowDir != "" && !strings.HasPrefix(importPath, "/") { + if isLocalFileForUpdate(localWorkflowDir, importPath) { + importsLog.Printf("Import path exists locally, preserving relative path: %s", importPath) + processed = append(processed, importPath) + continue + } + } resolvedPath := resolveImportPath(importPath, workflow.WorkflowPath) importsLog.Printf("Resolved import path: %s -> %s (workflow: %s)", importPath, resolvedPath, workflow.WorkflowPath) workflowSpec := buildWorkflowSpecRef(workflow.RepoSlug, resolvedPath, commitSHA, workflow.Version) @@ -317,10 +333,12 @@ func processIncludesWithWorkflowSpec(content string, workflow *WorkflowSpec, com } // processIncludesInContent processes @include directives in workflow content for update command -// and also processes imports field in frontmatter -func processIncludesInContent(content string, workflow *WorkflowSpec, commitSHA string, verbose bool) (string, error) { +// and also processes imports field in frontmatter. +// If localWorkflowDir is non-empty, any relative import/include path whose file exists under +// that directory is left as-is rather than being rewritten to a cross-repo reference. +func processIncludesInContent(content string, workflow *WorkflowSpec, commitSHA string, localWorkflowDir string, verbose bool) (string, error) { // First process imports field in frontmatter - processedImportsContent, err := processImportsWithWorkflowSpec(content, workflow, commitSHA, verbose) + processedImportsContent, err := processImportsWithWorkflowSpec(content, workflow, commitSHA, localWorkflowDir, verbose) if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to process imports: %v", err))) @@ -367,6 +385,15 @@ func processIncludesInContent(content string, workflow *WorkflowSpec, commitSHA continue } + // Preserve relative @include paths whose files exist in the local workflow directory. + if localWorkflowDir != "" && !strings.HasPrefix(filePath, "/") { + if isLocalFileForUpdate(localWorkflowDir, filePath) { + importsLog.Printf("Include path exists locally, preserving: %s", filePath) + result.WriteString(line + "\n") + continue + } + } + // Resolve the file path relative to the workflow file's directory resolvedPath := resolveImportPath(filePath, workflow.WorkflowPath) @@ -393,7 +420,28 @@ func processIncludesInContent(content string, workflow *WorkflowSpec, commitSHA return result.String(), scanner.Err() } -// isWorkflowSpecFormat checks if a path already looks like a workflowspec +// isLocalFileForUpdate returns true when importPath resolves to an existing file +// within localWorkflowDir. The resolved absolute path must stay inside localWorkflowDir +// to guard against path traversal (e.g. "../../etc/passwd" in import paths). +// importPath must be a relative path — callers must not pass absolute paths here. +func isLocalFileForUpdate(localWorkflowDir, importPath string) bool { + if localWorkflowDir == "" || importPath == "" { + return false + } + localPath := filepath.Join(localWorkflowDir, importPath) + absDir, err1 := filepath.Abs(localWorkflowDir) + absPath, err2 := filepath.Abs(localPath) + if err1 != nil || err2 != nil { + return false + } + // Reject traversal attempts: the resolved path must be a child of localWorkflowDir + if !strings.HasPrefix(absPath, absDir+string(filepath.Separator)) { + return false + } + _, statErr := os.Stat(localPath) + return statErr == nil +} + // A workflowspec is identified by having an @ version indicator (e.g., owner/repo/path@sha) // Simple paths like "shared/mcp/file.md" are NOT workflowspecs and should be processed func isWorkflowSpecFormat(path string) bool { diff --git a/pkg/cli/imports_test.go b/pkg/cli/imports_test.go index e44bc6735e3..8b1dfa68f61 100644 --- a/pkg/cli/imports_test.go +++ b/pkg/cli/imports_test.go @@ -3,6 +3,7 @@ package cli import ( + "os" "strings" "testing" ) @@ -167,7 +168,7 @@ engine: claude }, } - result, err := processIncludesInContent(content, workflow, "", false) + result, err := processIncludesInContent(content, workflow, "", "", false) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -197,7 +198,7 @@ engine: claude }, } - result, err := processIncludesInContent(content, workflow, "", false) + result, err := processIncludesInContent(content, workflow, "", "", false) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -352,7 +353,7 @@ Test content. commitSHA := "abc123def456" - result, err := processImportsWithWorkflowSpec(content, workflow, commitSHA, false) + result, err := processImportsWithWorkflowSpec(content, workflow, commitSHA, "", false) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -383,3 +384,173 @@ Test content. } } } + +// TestProcessImportsWithWorkflowSpec_PreservesLocalRelativePaths tests that when +// localWorkflowDir is provided and import files exist on disk, the relative paths +// are kept as-is and NOT rewritten to cross-repo workflowspec references. +// This is the fix for: gh aw update rewrites local imports: to cross-repo paths. +func TestProcessImportsWithWorkflowSpec_PreservesLocalRelativePaths(t *testing.T) { + // Create a temporary directory to act as the local workflow directory + tmpDir := t.TempDir() + + // Create the shared import files locally + for _, rel := range []string{"shared/team-config.md", "shared/aor-index.md"} { + dir := tmpDir + "/" + rel[:strings.LastIndex(rel, "/")] + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create dir %s: %v", dir, err) + } + if err := os.WriteFile(tmpDir+"/"+rel, []byte("# Shared content"), 0644); err != nil { + t.Fatalf("Failed to create file %s: %v", rel, err) + } + } + + content := `--- +engine: copilot +imports: + - shared/team-config.md + - shared/aor-index.md +--- + +# Investigate +` + + workflow := &WorkflowSpec{ + RepoSpec: RepoSpec{ + RepoSlug: "github/identity-core", + Version: "cd32c168", + }, + WorkflowPath: ".github/workflows/investigate.md", + } + + result, err := processImportsWithWorkflowSpec(content, workflow, "cd32c168", tmpDir, false) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Local paths must be preserved as-is + if !strings.Contains(result, "- shared/team-config.md") { + t.Errorf("Expected local import 'shared/team-config.md' to be preserved, got:\n%s", result) + } + if !strings.Contains(result, "- shared/aor-index.md") { + t.Errorf("Expected local import 'shared/aor-index.md' to be preserved, got:\n%s", result) + } + + // Cross-repo refs must NOT appear + if strings.Contains(result, "github/identity-core") { + t.Errorf("Cross-repo ref should NOT appear when local file exists, got:\n%s", result) + } +} + +// TestProcessImportsWithWorkflowSpec_RewritesWhenLocalMissing verifies that imports +// for files that do NOT exist locally are still rewritten to cross-repo refs. +func TestProcessImportsWithWorkflowSpec_RewritesWhenLocalMissing(t *testing.T) { + // Use a temp dir that has NO shared files + tmpDir := t.TempDir() + + content := `--- +engine: copilot +imports: + - shared/team-config.md +--- + +# Investigate +` + + workflow := &WorkflowSpec{ + RepoSpec: RepoSpec{ + RepoSlug: "github/identity-core", + Version: "cd32c168", + }, + WorkflowPath: ".github/workflows/investigate.md", + } + + result, err := processImportsWithWorkflowSpec(content, workflow, "cd32c168", tmpDir, false) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // File does NOT exist locally → must be rewritten to cross-repo ref + expectedRef := "github/identity-core/.github/workflows/shared/team-config.md@cd32c168" + if !strings.Contains(result, expectedRef) { + t.Errorf("Expected cross-repo ref '%s' when file is missing locally, got:\n%s", expectedRef, result) + } + + // Original relative path must be gone + if strings.Contains(result, "- shared/team-config.md") { + t.Errorf("Relative path should have been rewritten when file is missing locally, got:\n%s", result) + } +} + +// TestProcessIncludesInContent_PreservesLocalIncludeDirectives tests that @include +// directives whose files exist locally are not rewritten to cross-repo refs. +func TestProcessIncludesInContent_PreservesLocalIncludeDirectives(t *testing.T) { + // Create a temporary directory with the shared include file + tmpDir := t.TempDir() + if err := os.MkdirAll(tmpDir+"/shared", 0755); err != nil { + t.Fatalf("Failed to create shared dir: %v", err) + } + if err := os.WriteFile(tmpDir+"/shared/config.md", []byte("# Config"), 0644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + content := `--- +engine: copilot +--- + +# Test Workflow + +{{#import shared/config.md}} +` + + workflow := &WorkflowSpec{ + RepoSpec: RepoSpec{ + RepoSlug: "github/identity-core", + Version: "abc123", + }, + WorkflowPath: ".github/workflows/test.md", + } + + result, err := processIncludesInContent(content, workflow, "abc123", tmpDir, false) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Local include directive must be preserved + if !strings.Contains(result, "{{#import shared/config.md}}") { + t.Errorf("Expected local @include to be preserved, got:\n%s", result) + } + + // Cross-repo ref must NOT appear + if strings.Contains(result, "github/identity-core") { + t.Errorf("Cross-repo ref should NOT appear when local file exists, got:\n%s", result) + } +} + +// TestIsLocalFileForUpdate_PathTraversal ensures that traversal attempts (e.g. +// "../../etc/passwd") are rejected even if the target path happens to exist. +func TestIsLocalFileForUpdate_PathTraversal(t *testing.T) { + tmpDir := t.TempDir() + + // Traversal path that would escape tmpDir + traversal := "../../etc/passwd" + if isLocalFileForUpdate(tmpDir, traversal) { + t.Errorf("isLocalFileForUpdate should reject path traversal attempt: %s", traversal) + } + + // A normal path within tmpDir that doesn't exist should return false + if isLocalFileForUpdate(tmpDir, "nonexistent.md") { + t.Errorf("isLocalFileForUpdate should return false for non-existent file") + } + + // A normal path within tmpDir that DOES exist should return true + validFile := "shared/file.md" + if err := os.MkdirAll(tmpDir+"/shared", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpDir+"/"+validFile, []byte("content"), 0644); err != nil { + t.Fatal(err) + } + if !isLocalFileForUpdate(tmpDir, validFile) { + t.Errorf("isLocalFileForUpdate should return true for an existing file within tmpDir") + } +} diff --git a/pkg/cli/update_command_test.go b/pkg/cli/update_command_test.go index 22c0acbb327..bc48b591164 100644 --- a/pkg/cli/update_command_test.go +++ b/pkg/cli/update_command_test.go @@ -56,7 +56,7 @@ This is the base content.` oldSourceSpec := "test/repo/workflow.md@v1.0.0" newRef := "v1.1.0" - merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, false) + merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, "", false) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -113,7 +113,7 @@ This is the upstream modified content.` oldSourceSpec := "test/repo/workflow.md@v1.0.0" newRef := "v1.1.0" - merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, false) + merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, "", false) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -187,7 +187,7 @@ Base content here.` oldSourceSpec := "test/repo/workflow.md@v1.0.0" newRef := "v1.1.0" - merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, false) + merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, "", false) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -243,7 +243,7 @@ Content remains the same.` oldSourceSpec := "test/repo/workflow.md@v1.0.0" newRef := "v1.1.0" - merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, false) + merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, "", false) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -338,7 +338,7 @@ Base content with upstream notes.` oldSourceSpec := "test/repo/workflow.md@v1.0.0" newRef := "v1.1.0" - merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, true) + merged, hasConflicts, err := MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, "", true) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -645,7 +645,7 @@ source: test/repo/workflow.md@v1.0.0 for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := hasLocalModifications(tt.sourceContent, tt.localContent, tt.sourceSpec, false) + result := hasLocalModifications(tt.sourceContent, tt.localContent, tt.sourceSpec, "", false) if result != tt.expectModified { t.Errorf("%s: expected modified=%v, got %v", tt.description, tt.expectModified, result) diff --git a/pkg/cli/update_merge.go b/pkg/cli/update_merge.go index deee71961a8..f86848c83f2 100644 --- a/pkg/cli/update_merge.go +++ b/pkg/cli/update_merge.go @@ -17,7 +17,9 @@ var updateMergeLog = logger.New("cli:update_merge") // hasLocalModifications checks if the local workflow file has been modified from its source // It resolves the source field and imports on the remote content, then compares with local // Note: stop-after field is ignored during comparison as it's a deployment-specific setting -func hasLocalModifications(sourceContent, localContent, sourceSpec string, verbose bool) bool { +// localWorkflowDir, if non-empty, is passed to import processing so that relative import paths +// whose files exist locally are preserved — giving an accurate comparison against local content. +func hasLocalModifications(sourceContent, localContent, sourceSpec, localWorkflowDir string, verbose bool) bool { updateMergeLog.Printf("Checking for local modifications: source_spec=%s", sourceSpec) // Normalize both contents sourceNormalized := stringutil.NormalizeWhitespace(sourceContent) @@ -57,7 +59,7 @@ func hasLocalModifications(sourceContent, localContent, sourceSpec string, verbo WorkflowPath: parsedSourceSpec.Path, } - sourceResolved, err := processIncludesInContent(sourceWithSource, workflow, parsedSourceSpec.Ref, verbose) + sourceResolved, err := processIncludesInContent(sourceWithSource, workflow, parsedSourceSpec.Ref, localWorkflowDir, verbose) if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Failed to process imports on remote content: %v", err))) @@ -83,7 +85,10 @@ func hasLocalModifications(sourceContent, localContent, sourceSpec string, verbo // MergeWorkflowContent performs a 3-way merge of workflow content using git merge-file // It returns the merged content, whether conflicts exist, and any error -func MergeWorkflowContent(base, current, new, oldSourceSpec, newRef string, verbose bool) (string, bool, error) { +// localWorkflowPath is the filesystem path of the local workflow file being updated; +// when non-empty its directory is used to preserve relative import paths whose files +// exist locally rather than rewriting them to cross-repo references. +func MergeWorkflowContent(base, current, new, oldSourceSpec, newRef, localWorkflowPath string, verbose bool) (string, bool, error) { updateMergeLog.Printf("Starting 3-way merge: old_ref=%s, new_ref=%s", oldSourceSpec, newRef) if verbose { @@ -207,7 +212,11 @@ func MergeWorkflowContent(base, current, new, oldSourceSpec, newRef string, verb WorkflowPath: sourceSpec.Path, } - processedContent, err := processIncludesInContent(mergedStr, workflow, newRef, verbose) + localWorkflowDir := "" + if localWorkflowPath != "" { + localWorkflowDir = filepath.Dir(localWorkflowPath) + } + processedContent, err := processIncludesInContent(mergedStr, workflow, newRef, localWorkflowDir, verbose) if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to process includes: %v", err))) diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index a8b8643a2ce..b70e43a181d 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -403,7 +403,7 @@ func updateWorkflow(wf *workflowWithSource, allowMajor, force, verbose bool, eng } // Check if local file differs from source - if hasLocalModifications(string(sourceContent), string(currentContent), wf.SourceSpec, verbose) { + if hasLocalModifications(string(sourceContent), string(currentContent), wf.SourceSpec, filepath.Dir(wf.Path), verbose) { updateLog.Printf("Local modifications detected in workflow: %s", wf.Name) fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow %s is already up to date (%s)", wf.Name, shortRef(currentRef)))) fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("⚠️ Local copy of %s has been modified from source", wf.Name))) @@ -436,7 +436,7 @@ func updateWorkflow(wf *workflowWithSource, allowMajor, force, verbose bool, eng baseContent, dlErr := downloadWorkflowContent(sourceSpec.Repo, sourceSpec.Path, currentRef, verbose) if dlErr == nil { localContent, readErr := os.ReadFile(wf.Path) - if readErr == nil && hasLocalModifications(string(baseContent), string(localContent), wf.SourceSpec, verbose) { + if readErr == nil && hasLocalModifications(string(baseContent), string(localContent), wf.SourceSpec, filepath.Dir(wf.Path), verbose) { updateLog.Printf("Local modifications detected in %s, merging to preserve changes", wf.Name) fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Local modifications detected in %s, merging to preserve your changes", wf.Name))) } else { @@ -474,7 +474,7 @@ func updateWorkflow(wf *workflowWithSource, allowMajor, force, verbose bool, eng // Perform 3-way merge using git merge-file updateLog.Printf("Performing 3-way merge for workflow: %s", wf.Name) - mergedContent, conflicts, err := MergeWorkflowContent(string(baseContent), string(currentContent), string(newContent), wf.SourceSpec, sourceFieldRef, verbose) + mergedContent, conflicts, err := MergeWorkflowContent(string(baseContent), string(currentContent), string(newContent), wf.SourceSpec, sourceFieldRef, wf.Path, verbose) if err != nil { updateLog.Printf("Merge failed for workflow %s: %v", wf.Name, err) return fmt.Errorf("failed to merge workflow content: %w", err) @@ -513,7 +513,7 @@ func updateWorkflow(wf *workflowWithSource, allowMajor, force, verbose bool, eng WorkflowPath: sourceSpec.Path, } - processedContent, err := processIncludesInContent(finalContent, workflow, latestRef, verbose) + processedContent, err := processIncludesInContent(finalContent, workflow, latestRef, filepath.Dir(wf.Path), verbose) if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to process includes: %v", err)))