diff --git a/pkg/cli/includes.go b/pkg/cli/includes.go index 3aedb871723..bf6a0d9baa4 100644 --- a/pkg/cli/includes.go +++ b/pkg/cli/includes.go @@ -22,12 +22,128 @@ import ( // includeDirectivePattern matches @include or @include? directives with their path argument var includeDirectivePattern = regexp.MustCompile(`^@include(\?)?\s+(.+)$`) var downloadRemoteImportFile = parser.DownloadFileFromGitHub +var downloadRemoteRuntimeImportFile = parser.DownloadFileFromGitHubForHost // includesFetcher is the function type used by fetchAndSaveRemoteIncludes to retrieve // a single include file. Passing a non-nil value overrides the default FetchIncludeFromSource // implementation; this is used in tests to avoid real network calls. type includesFetcher func(ctx context.Context, includePath string, baseSpec *WorkflowSpec, verbose bool) ([]byte, string, error) +type runtimeImportFetcher func(ctx context.Context, owner, repo, path, ref, host string) ([]byte, error) + +type runtimeImportOpts struct { + owner string + repo string + ref string + host string + repoRoot string + verbose bool + force bool + tracker *FileTracker + seen map[string]struct{} + downloadFn runtimeImportFetcher +} + +func fetchAndSaveRemoteRuntimeImports(ctx context.Context, content string, spec *WorkflowSpec, targetDir string, verbose bool, force bool, tracker *FileTracker) error { + if spec.RepoSlug == "" { + return nil + } + + parts := strings.SplitN(spec.RepoSlug, "/", 2) + if len(parts) != 2 { + return nil + } + + ref := spec.Version + if ref == "" { + defaultBranch, err := getRepoDefaultBranch(ctx, spec.RepoSlug) + if err != nil { + remoteWorkflowLog.Printf("Failed to resolve default branch for %s, falling back to 'main': %v", spec.RepoSlug, err) + ref = "main" + } else { + ref = defaultBranch + } + spec.Version = ref + } + + opts := runtimeImportOpts{ + owner: parts[0], + repo: parts[1], + ref: ref, + host: spec.Host, + repoRoot: filepath.Dir(filepath.Dir(targetDir)), + verbose: verbose, + force: force, + tracker: tracker, + seen: make(map[string]struct{}), + downloadFn: downloadRemoteRuntimeImportFile, + } + + return fetchRemoteRuntimeImportsRecursive(ctx, content, getParentDir(spec.WorkflowPath), opts) +} + +func fetchRemoteRuntimeImportsRecursive(ctx context.Context, content, currentBaseDir string, opts runtimeImportOpts) error { + body := content + if parsed, err := parser.ExtractFrontmatterFromContent(content); err == nil { + body = parsed.Markdown + } + + for _, imp := range parser.ExtractBodyLevelImportPaths(body, currentBaseDir) { + remoteFilePath := strings.TrimSpace(imp.Path) + if remoteFilePath == "" { + continue + } + if rest, ok := strings.CutPrefix(remoteFilePath, "/"); ok { + remoteFilePath = rest + } + remoteFilePath = path.Clean(remoteFilePath) + if remoteFilePath == "." || remoteFilePath == ".." || strings.HasPrefix(remoteFilePath, "../") { + return fmt.Errorf("runtime-import path %q escapes repository root", imp.Path) + } + if setutil.Contains(opts.seen, remoteFilePath) { + continue + } + opts.seen[remoteFilePath] = struct{}{} + + targetPath := filepath.Join(opts.repoRoot, filepath.FromSlash(remoteFilePath)) + if err := fileutil.ValidatePathWithinBase(opts.repoRoot, targetPath); err != nil { + return fmt.Errorf("refusing to write runtime import outside repository root: %w", err) + } + + fileExists := false + if fileutil.FileExists(targetPath) { + fileExists = true + if !opts.force { + continue + } + } + + importContent, err := opts.downloadFn(ctx, opts.owner, opts.repo, remoteFilePath, opts.ref, opts.host) + if err != nil { + return fmt.Errorf("failed to fetch runtime import %s: %w", remoteFilePath, err) + } + if err := os.MkdirAll(filepath.Dir(targetPath), constants.DirPermPublic); err != nil { + return fmt.Errorf("failed to create directory for runtime import %s: %w", remoteFilePath, err) + } + if err := os.WriteFile(targetPath, importContent, constants.FilePermSensitive); err != nil { + return fmt.Errorf("failed to write runtime import %s: %w", remoteFilePath, err) + } + if opts.tracker != nil { + if fileExists { + opts.tracker.TrackModified(targetPath) + } else { + opts.tracker.TrackCreated(targetPath) + } + } + + if err := fetchRemoteRuntimeImportsRecursive(ctx, string(importContent), path.Dir(remoteFilePath), opts); err != nil { + return err + } + } + + return nil +} + // FetchIncludeFromSource fetches an include file from GitHub directly using a workflowspec format path. // The includePath should be in the format: owner/repo/path/to/file.md[@ref] // If the includePath is a relative path, it's resolved relative to the baseSpec. @@ -586,6 +702,14 @@ func fetchAllRemoteDependencies(ctx context.Context, content string, spec *Workf fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to fetch frontmatter import dependencies: %v", err))) } } + // Fetch and save required runtime-import dependencies so installs include the + // explicit runtime-import closure without copying unrelated .github contents. + if err := fetchAndSaveRemoteRuntimeImports(ctx, content, spec, targetDir, verbose, force, tracker); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Failed to fetch runtime-import dependencies; activation may fail")) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(err.Error())) + } + } // Fetch and save workflows referenced in safe-outputs.dispatch-workflow so they are // available locally. Workflow names using GitHub Actions expression syntax are skipped. if err := fetchAndSaveRemoteDispatchWorkflows(ctx, content, spec, targetDir, verbose, force, tracker); err != nil { diff --git a/pkg/cli/remote_workflow_test.go b/pkg/cli/remote_workflow_test.go index d136d65935f..40119e4ce00 100644 --- a/pkg/cli/remote_workflow_test.go +++ b/pkg/cli/remote_workflow_test.go @@ -858,6 +858,79 @@ imports: "transitive dep must not be written at the root level") } +func TestFetchAndSaveRemoteRuntimeImports_EmptyRepoSlug(t *testing.T) { + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0o755)) + + err := fetchAndSaveRemoteRuntimeImports( + t.Context(), + "{{#runtime-import .github/skills/example/SKILL.md}}\n", + &WorkflowSpec{}, + workflowsDir, + false, + false, + nil, + ) + require.NoError(t, err) + assert.NoFileExists(t, filepath.Join(tmpDir, ".github", "skills", "example", "SKILL.md")) +} + +func TestFetchAndSaveRemoteRuntimeImports_FetchesPinnedRecursiveClosure(t *testing.T) { + original := downloadRemoteRuntimeImportFile + defer func() { downloadRemoteRuntimeImportFile = original }() + + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0o755)) + + type request struct { + path string + ref string + host string + } + var requests []request + downloadRemoteRuntimeImportFile = func(_ context.Context, owner, repo, remotePath, ref, host string) ([]byte, error) { + assert.Equal(t, "github", owner) + assert.Equal(t, "gh-aw", repo) + requests = append(requests, request{path: remotePath, ref: ref, host: host}) + switch remotePath { + case ".github/skills/example/SKILL.md": + return []byte("---\nname: Example\n---\n{{#runtime-import ./nested.md}}\n"), nil + case ".github/skills/example/nested.md": + return []byte("nested\n"), nil + default: + return nil, fmt.Errorf("unexpected path %s", remotePath) + } + } + + spec := &WorkflowSpec{ + RepoSpec: RepoSpec{ + RepoSlug: "github/gh-aw", + Version: "0123456789abcdef0123456789abcdef01234567", + }, + WorkflowPath: ".github/workflows/example.md", + Host: "github.com", + } + + err := fetchAndSaveRemoteRuntimeImports( + t.Context(), + "{{#runtime-import .github/skills/example/SKILL.md}}\n", + spec, + workflowsDir, + false, + true, + nil, + ) + require.NoError(t, err) + assert.Equal(t, []request{ + {path: ".github/skills/example/SKILL.md", ref: "0123456789abcdef0123456789abcdef01234567", host: "github.com"}, + {path: ".github/skills/example/nested.md", ref: "0123456789abcdef0123456789abcdef01234567", host: "github.com"}, + }, requests) + assert.FileExists(t, filepath.Join(tmpDir, ".github", "skills", "example", "SKILL.md")) + assert.FileExists(t, filepath.Join(tmpDir, ".github", "skills", "example", "nested.md")) +} + // TestFetchFrontmatterImportsRecursive_RepoRootSlashPath verifies that when // originalBaseDir is empty (workflow lives at the repo root) and an import // path contains a "/" (e.g. "shared/helper.md"), the path is used as-is diff --git a/pkg/cli/trial_repository.go b/pkg/cli/trial_repository.go index 2aafd7be8b7..085de5bb6ee 100644 --- a/pkg/cli/trial_repository.go +++ b/pkg/cli/trial_repository.go @@ -306,9 +306,14 @@ func installWorkflowInTrialMode(ctx context.Context, tempDir string, parsedSpec } } - // Fetch and save all remote dependencies (includes, imports, dispatch workflows, resources) + // Fetch and save all remote dependencies (includes, imports, runtime imports, + // dispatch workflows, resources) using the exact commit resolved for this install. if !fetched.IsLocal { - if err := fetchAllRemoteDependencies(ctx, string(content), parsedSpec, result.WorkflowsDir, opts.Verbose, true, nil); err != nil { + depSpec := *parsedSpec + if fetched.CommitSHA != "" { + depSpec.Version = fetched.CommitSHA + } + if err := fetchAllRemoteDependencies(ctx, string(content), &depSpec, result.WorkflowsDir, opts.Verbose, true, nil); err != nil { return fmt.Errorf("failed to fetch remote dependencies: %w", err) } }