Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions pkg/cli/includes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions pkg/cli/remote_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions pkg/cli/trial_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Loading