From 2419c947f2d6950941b10a0a5670fa9a86728fbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:15:41 +0000 Subject: [PATCH 1/5] Initial plan From a755e757c3fe70c4c863c230de7abf69934983d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:44:20 +0000 Subject: [PATCH 2/5] Support source-to-destination mappings in aw.yml includes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/aw-yml-package-manifest.md | 38 +- ...pository-package-manifest-specification.md | 36 +- pkg/cli/add_command.go | 6 + pkg/cli/add_package_manifest.go | 327 +++++++++++++--- pkg/cli/add_package_manifest_mapping_test.go | 348 ++++++++++++++++++ pkg/cli/add_package_manifest_test.go | 46 ++- pkg/cli/add_workflow_resolution.go | 98 +++-- ...d_workflow_resolution_manifest_ref_test.go | 4 +- pkg/cli/compile_repository_manifest.go | 5 +- pkg/cli/spec.go | 4 + pkg/cli/update_manifest.go | 14 +- pkg/parser/schemas/aw_manifest_schema.json | 31 +- 12 files changed, 850 insertions(+), 107 deletions(-) create mode 100644 pkg/cli/add_package_manifest_mapping_test.go diff --git a/docs/src/content/docs/reference/aw-yml-package-manifest.md b/docs/src/content/docs/reference/aw-yml-package-manifest.md index 8a7bab8a4fb..db2d70331a2 100644 --- a/docs/src/content/docs/reference/aw-yml-package-manifest.md +++ b/docs/src/content/docs/reference/aw-yml-package-manifest.md @@ -30,7 +30,8 @@ The package root is the folder that contains `aw.yml`. | `name` | string | Yes | Human-readable package name. Must be non-empty after trimming whitespace. | | `emoji` | string | No | Optional package emoji for display in package metadata. | | `description` | string | No | Optional package description. `gh aw add` warns when it exceeds 255 characters. | -| `files` | array of strings | No | Package-root-relative paths. Agentic markdown workflows under `workflows/` or `.github/workflows/`; raw GitHub Actions YAML (`.yml`) is also accepted as direct children of `.github/workflows/`. | +| `files` | array of strings | No | Deprecated; use `includes`. Package-root-relative paths. Agentic markdown workflows under `workflows/` or `.github/workflows/`; raw GitHub Actions YAML (`.yml`) is also accepted as direct children of `.github/workflows/`. | +| `includes` | array | No | Installable entries. Each entry is either a path string (same rules as `files`, plus skill and agent paths) or a source-to-destination mapping. | ## Installable workflows @@ -39,6 +40,35 @@ If `files` is present, valid entries become the install bundle. Two entry kinds - **Agentic workflow markdown** โ€” paths ending in `.md` under `workflows/` or `.github/workflows/`. `gh aw add` compiles these to lock files and fetches their dependencies. - **Raw GitHub Actions YAML** โ€” paths ending in `.yml` (but not `.lock.yml`) that are direct children of `.github/workflows/`. `gh aw add` copies these verbatim to `.github/workflows/.yml` with no frontmatter processing, no dependency fetch, and no compilation. Nested subdirectories under `.github/workflows/` and `.yml` files under `workflows/` are not accepted. +### Path resolution rules + +- A **string entry** that starts with `.github/` is resolved relative to the **consuming repository root**, even inside a nested package. For example, `.github/workflows/nightly.md` in `factory/aw.yml` refers to the repository-root file, not to `factory/.github/workflows/nightly.md`. +- Every other string entry (such as `workflows/review.md`) is resolved relative to the package root. +- A **mapping entry** always resolves `source` relative to the package root and `destination` relative to the consuming repository root. + +### Source-to-destination mappings + +Use mapping entries to keep workflow assets inert in the distribution repository while still installing them into the consuming repository's `.github/workflows/`: + +```yaml +name: Factory +includes: + - source: payload/workflows/reviewer.md + destination: .github/workflows/reviewer.md + kind: agentic-workflow + - source: payload/workflows/controller.yml + destination: .github/workflows/controller.yml + kind: action-workflow +``` + +With a nested package reference such as `owner/repo/factory`, the files above are fetched from `factory/payload/workflows/` and installed to `.github/workflows/`. Because the sources live outside `.github/workflows/` in the distribution repository, they never run there. + +The optional `kind` field is either `agentic-workflow` (`.md`) or `action-workflow` (`.yml`) and must match the source extension. + +Mappings are rejected when `source` or `destination` is absolute, contains `..`, points at a symbolic link, uses an unsupported extension (or `.lock.yml`), changes the file extension between source and destination, or targets anything other than a direct child of `.github/workflows/`. Two entries installing to the same destination are rejected before any file is written. + +`gh aw add`, `gh aw add-wizard`, and `gh aw update` all use these same mapping rules. + If `files` is omitted, or no valid entries remain after filtering, `gh aw add` discovers installable markdown files under: @@ -60,8 +90,10 @@ Missing `README.md` causes package validation to fail. name: Repo Assist emoji: ๐Ÿค– description: Friendly repository automation for review and issue triage -files: +includes: - workflows/review.md # agentic workflow โ€” compiled on install - - .github/workflows/nightly-review.md + - .github/workflows/nightly-review.md # repository-root-relative string entry - .github/workflows/ci.yml # raw Actions YAML โ€” copied verbatim + - source: payload/workflows/reviewer.md # package-relative source + destination: .github/workflows/reviewer.md ``` diff --git a/docs/src/content/docs/specs/repository-package-manifest-specification.md b/docs/src/content/docs/specs/repository-package-manifest-specification.md index 8d73e93b54d..393c82b8d18 100644 --- a/docs/src/content/docs/specs/repository-package-manifest-specification.md +++ b/docs/src/content/docs/specs/repository-package-manifest-specification.md @@ -47,7 +47,8 @@ The manifest document MUST be a YAML mapping. Unknown top-level fields MUST be r | `emoji` | string | No | Optional package emoji for display in package metadata. | | `description` | string | No | Human-readable package description. | | `license` | string | No | SPDX license identifier or license name for the package. | -| `files` | array of strings | No | Explicit installable workflow file list. | +| `files` | array of strings | No | Deprecated. Explicit installable workflow file list. Use `includes` instead. | +| `includes` | array of strings or mappings | No | Explicit installable package entries. String entries use path conventions; mapping entries declare an explicit source-to-destination install path. | ### 4.2 `manifest-version` @@ -94,6 +95,37 @@ Duplicate entries SHOULD be ignored after normalization. **Path-traversal safety**: Each entry in `files` MUST NOT contain a path-traversal sequence. Specifically, any entry that contains `../` (or `..\` on Windows-style paths), begins with `../`, or resolves to a path outside the package root after normalization MUST be rejected with a validation error. Implementations MUST NOT follow symlinks that would escape the package root during file resolution. This rule applies regardless of the number of traversal components in the path (e.g., `../../etc/passwd` and `workflows/../../hidden` are both prohibited). +### 4.9 `includes` + +If present, `includes` MUST be an array whose entries are either strings or mappings. + +**String entries** follow the same rules as `files` (ยง4.8), with one special case that MUST be preserved for backward compatibility: a string entry beginning with `.github/` is resolved relative to the **consuming repository root**, not relative to the package root, even for nested packages. All other string entries (for example `workflows/review.md`) are resolved relative to the package root. + +**Mapping entries** declare an explicit source-to-destination install mapping and MUST contain: + +| Key | Type | Required | Meaning | +| --- | --- | --- | --- | +| `source` | string | Yes | Path of the file to install, always resolved relative to the package root, including for nested packages. The `.github/` special case of string entries MUST NOT apply. | +| `destination` | string | Yes | Install path, resolved relative to the consuming repository root. | +| `kind` | string | No | Either `agentic-workflow` or `action-workflow`. When present, it MUST match the file extension of `source`. | + +Mapping entries let a distribution repository keep executable workflow assets inert outside its own `.github/workflows/` directory and still install them into the consuming repository's `.github/workflows/`. + +Implementations MUST reject a mapping entry when any of the following holds: + +- `source` or `destination` is empty, absolute, or contains a path-traversal sequence that escapes its root; +- `source` resolves to a symbolic link or to a path outside the package root; +- `source` or `destination` does not end in `.md` or `.yml`, or ends in `.lock.yml`; +- `destination` is not a direct child of `.github/workflows/`; +- the file extension of `destination` differs from the file extension of `source`; +- `kind` is present and does not match the kind implied by the `source` extension. + +Implementations MUST detect two entries that resolve to the same `destination` and MUST fail before writing any file. + +Mapping entries follow the same install semantics as string entries: `.md` sources are compiled under their destination file name, and `.yml` sources are copied verbatim. Package-provided post-install shell code MUST NOT be executed. + +`gh aw add`, `gh aw add-wizard`, and `gh aw update` MUST use identical mapping semantics, and `gh aw update` MUST continue to track the manifest source of installed files. + ## 5. Installable file resolution Supported installable paths are: @@ -102,6 +134,8 @@ Supported installable paths are: - `.github/workflows/.md` - `.github/workflows/.yml` (raw GitHub Actions YAML; direct children only, `.lock.yml` excluded) +Mapping entries in `includes` (ยง4.9) may declare any package-relative `source`; their `destination` MUST be a direct child of `.github/workflows/`. + Nested descendants under the markdown directories are also valid when referenced explicitly in `files`. Raw `.yml` action workflows MUST be direct children of `.github/workflows/`; nested `.yml` files are rejected. Raw `.yml` action workflows are installed verbatim: `gh aw add` copies the file to `.github/workflows/.yml` and performs no frontmatter parsing, no dependency resolution, and no compilation. No `.lock.yml` is produced. diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index 1146343a1c3..6474faf3eb3 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path" "path/filepath" "strings" @@ -390,6 +391,11 @@ func addWorkflowWithTracking(ctx context.Context, resolved *ResolvedWorkflow, tr workflowName := workflowSpec.WorkflowName if opts.Name != "" { workflowName = opts.Name + } else if workflowSpec.DestinationPath != "" { + // Package manifests may map a package-relative source to an explicit + // repository-root-relative destination under .github/workflows/. + base := path.Base(workflowSpec.DestinationPath) + workflowName = strings.TrimSuffix(base, path.Ext(base)) } // Action workflow files (.yml) are copied as-is to .github/workflows/ without any // frontmatter processing, dependency fetching, or compilation. diff --git a/pkg/cli/add_package_manifest.go b/pkg/cli/add_package_manifest.go index d5326107b69..19b33049289 100644 --- a/pkg/cli/add_package_manifest.go +++ b/pkg/cli/add_package_manifest.go @@ -49,13 +49,53 @@ type resolvedRepositoryPackage struct { Description string License string DocsPath string - InstallationSource []string + InstallationSource []resolvedPackageInstallable Bootstrap *repositoryPackageBootstrap SkillFiles []resolvedPackageSkillFile AgentFiles []string Warnings []string } +// resolvedPackageInstallable pairs the location a workflow file is read from with the +// path it is installed to in the consuming repository. +type resolvedPackageInstallable struct { + // SourcePath is where the file is fetched from. For remote packages it is a + // repository-relative path (e.g. "factory/payload/workflows/reviewer.md"); for local + // packages it is an absolute filesystem path. + SourcePath string + // DestinationPath is the repository-root-relative install path in the consuming + // repository (e.g. ".github/workflows/reviewer.md"). + DestinationPath string +} + +// packageInstallableSourcePaths returns the source paths of the given installables. +func packageInstallableSourcePaths(installables []resolvedPackageInstallable) []string { + paths := make([]string, 0, len(installables)) + for _, installable := range installables { + paths = append(paths, installable.SourcePath) + } + return paths +} + +// defaultPackageInstallDestination returns the repository-root-relative install path used +// for entries that do not declare an explicit destination. +func defaultPackageInstallDestination(sourcePath string) string { + return constants.WorkflowsDirSlash + path.Base(filepath.ToSlash(sourcePath)) +} + +// packageInstallablesFromSourcePaths converts plain source paths into installables using +// the default destination for each entry. +func packageInstallablesFromSourcePaths(sourcePaths []string) []resolvedPackageInstallable { + installables := make([]resolvedPackageInstallable, 0, len(sourcePaths)) + for _, sourcePath := range sourcePaths { + installables = append(installables, resolvedPackageInstallable{ + SourcePath: sourcePath, + DestinationPath: defaultPackageInstallDestination(sourcePath), + }) + } + return installables +} + // resolvedPackageSkillFile represents a single file within a skill directory that // should be installed to the agentic engine skill folder. type resolvedPackageSkillFile struct { @@ -157,21 +197,24 @@ func resolveRepositoryPackageRef(ctx context.Context, repoSpec *RepoSpec, host s return ref } -func resolveRepositoryPackageInstallablePaths(ctx context.Context, owner, repo, packagePath, ref, host string, manifest *repositoryPackageManifest, manifestPath string) ([]string, []string, []string, error) { +func resolveRepositoryPackageInstallablePaths(ctx context.Context, owner, repo, packagePath, ref, host string, manifest *repositoryPackageManifest, manifestPath string) ([]resolvedPackageInstallable, []string, []string, error) { includeInstallablePaths, includeSkillDirs, includeAgentFiles := splitManifestIncludePaths(manifest.Includes) - includeInstallablePaths = append(includeInstallablePaths, manifest.Files...) + includeInstallablePaths = append(includeInstallablePaths, manifestIncludesFromPaths(manifest.Files)...) installationSources := normalizePackageInstallablePaths(includeInstallablePaths, packagePath) if len(installationSources) == 0 { - var err error - installationSources, err = scanRepositoryPackageInstallablePaths(ctx, owner, repo, packagePath, ref, host) + scanned, err := scanRepositoryPackageInstallablePaths(ctx, owner, repo, packagePath, ref, host) if err != nil { return nil, nil, nil, err } + installationSources = packageInstallablesFromSourcePaths(scanned) } if err := validateUniqueManifestWorkflowFilenames(installationSources, manifestPath); err != nil { return nil, nil, nil, err } + if err := validateUniqueManifestInstallDestinations(installationSources, manifestPath); err != nil { + return nil, nil, nil, err + } return installationSources, includeSkillDirs, includeAgentFiles, nil } @@ -217,7 +260,7 @@ func resolveRepositoryPackageExtensionFiles(ctx context.Context, options reposit }, nil } -func newResolvedRepositoryPackage(manifestPath, ref, docsPath string, manifest *repositoryPackageManifest, installationSources []string, extensionFiles *repositoryPackageExtensionFiles, warnings []string) *resolvedRepositoryPackage { +func newResolvedRepositoryPackage(manifestPath, ref, docsPath string, manifest *repositoryPackageManifest, installationSources []resolvedPackageInstallable, extensionFiles *repositoryPackageExtensionFiles, warnings []string) *resolvedRepositoryPackage { return &resolvedRepositoryPackage{ ManifestPath: manifestPath, ResolvedRef: ref, @@ -259,7 +302,7 @@ type repositoryPackageManifest struct { Emoji string Description string License string - Includes []string + Includes []repositoryPackageInclude Files []string Bootstrap *repositoryPackageBootstrap Skills []string // skill directory paths (e.g. "skills/my-skill") @@ -362,7 +405,10 @@ func populateRepositoryPackageManifestMetadata(manifest *repositoryPackageManife } if includesValue, ok := root["includes"]; ok { - includes, includeWarnings := extractManifestIncludes(includesValue, manifestPath) + includes, includeWarnings, err := extractManifestIncludes(includesValue, manifestPath) + if err != nil { + return nil, err + } manifest.Includes = includes warnings = append(warnings, includeWarnings...) } @@ -401,27 +447,78 @@ func populateRepositoryPackageManifestMetadata(manifest *repositoryPackageManife return warnings, nil } -func extractManifestIncludes(value any, manifestPath string) ([]string, []string) { - var rawIncludes []string +// repositoryPackageInclude is a single entry of the manifest 'includes' array. Legacy +// string entries only set Source; object entries additionally set Destination (and +// optionally Kind). +type repositoryPackageInclude struct { + // Source is the entry path. For string entries it follows the historical rules + // (see isSupportedManifestIncludePath); for object entries it is always resolved + // relative to the package root. + Source string + // Destination is the repository-root-relative install path. Empty for string entries. + Destination string + // Kind is the optional declared entry kind ("agentic-workflow" or "action-workflow"). + Kind string +} + +// isMapping reports whether the entry uses the object form with an explicit destination. +func (include repositoryPackageInclude) isMapping() bool { + return include.Destination != "" +} + +const ( + manifestIncludeKindAgenticWorkflow = "agentic-workflow" + manifestIncludeKindActionWorkflow = "action-workflow" +) + +// manifestIncludesFromPaths converts plain path strings into legacy string-form entries. +func manifestIncludesFromPaths(paths []string) []repositoryPackageInclude { + includes := make([]repositoryPackageInclude, 0, len(paths)) + for _, p := range paths { + includes = append(includes, repositoryPackageInclude{Source: p}) + } + return includes +} + +func extractManifestIncludes(value any, manifestPath string) ([]repositoryPackageInclude, []string, error) { + var rawIncludes []repositoryPackageInclude + var warnings []string + appendRawEntry := func(item any) error { + if include, ok := stringValue(item); ok { + rawIncludes = append(rawIncludes, repositoryPackageInclude{Source: include}) + return nil + } + mapping, ok := item.(map[string]any) + if !ok { + warnings = append(warnings, fmt.Sprintf("Ignoring includes entry in %s because it is neither a string nor a source/destination mapping", manifestPath)) + return nil + } + include, err := parseManifestIncludeMapping(mapping, manifestPath) + if err != nil { + return err + } + rawIncludes = append(rawIncludes, include) + return nil + } + switch includes := value.(type) { case []any: for _, item := range includes { - if include, ok := stringValue(item); ok { - rawIncludes = append(rawIncludes, include) + if err := appendRawEntry(item); err != nil { + return nil, nil, err } } case []string: - rawIncludes = append(rawIncludes, includes...) + rawIncludes = append(rawIncludes, manifestIncludesFromPaths(includes)...) default: - return nil, []string{fmt.Sprintf("Ignoring includes entry in %s because it is not a list of strings", manifestPath)} + return nil, []string{fmt.Sprintf("Ignoring includes entry in %s because it is not a list", manifestPath)}, nil } - var warnings []string - normalized := make([]string, 0, len(rawIncludes)) - seen := make(map[string]struct{}) + normalized := make([]repositoryPackageInclude, 0, len(rawIncludes)) + seen := make(map[repositoryPackageInclude]struct{}) for _, include := range rawIncludes { - if !isSupportedManifestIncludePath(include) { - warnings = append(warnings, fmt.Sprintf("Ignoring includes entry %q in %s: use workflow files (workflows/, agentic-workflows/, .github/workflows/), skill directories (skills/, .github/skills/), or agent markdown files (agents/, .github/agents/)", include, manifestPath)) + if !include.isMapping() && !isSupportedManifestIncludePath(include.Source) { + warnings = append(warnings, fmt.Sprintf("Ignoring includes entry %q in %s: use workflow files (workflows/, agentic-workflows/, .github/workflows/), skill directories (skills/, .github/skills/), agent markdown files (agents/, .github/agents/), or a source/destination mapping", include.Source, manifestPath)) continue } if _, exists := seen[include]; exists { @@ -430,7 +527,108 @@ func extractManifestIncludes(value any, manifestPath string) ([]string, []string seen[include] = struct{}{} normalized = append(normalized, include) } - return normalized, warnings + return normalized, warnings, nil +} + +// parseManifestIncludeMapping validates an object-form includes entry and returns the +// normalized source, destination, and kind. +func parseManifestIncludeMapping(mapping map[string]any, manifestPath string) (repositoryPackageInclude, error) { + source, _ := stringValue(mapping["source"]) + destination, _ := stringValue(mapping["destination"]) + kind, _ := stringValue(mapping["kind"]) + source = strings.TrimSpace(source) + destination = strings.TrimSpace(destination) + kind = strings.TrimSpace(kind) + + if source == "" || destination == "" { + return repositoryPackageInclude{}, fmt.Errorf("invalid Agentic Workflow manifest %q: includes mapping entries require non-empty 'source' and 'destination'. Example:\nincludes:\n - source: payload/workflows/reviewer.md\n destination: %sreviewer.md", manifestPath, constants.WorkflowsDirSlash) + } + + cleanedSource, err := cleanManifestRelativePath(source) + if err != nil { + return repositoryPackageInclude{}, fmt.Errorf("invalid Agentic Workflow manifest %q: includes source %q is invalid: %w. Sources must be package-relative paths without '..' segments. Example:\nincludes:\n - source: payload/workflows/reviewer.md\n destination: %sreviewer.md", manifestPath, source, err, constants.WorkflowsDirSlash) + } + sourceKind, err := manifestIncludeKindForPath(cleanedSource) + if err != nil { + return repositoryPackageInclude{}, fmt.Errorf("invalid Agentic Workflow manifest %q: includes source %q is invalid: %w", manifestPath, source, err) + } + + cleanedDestination, err := cleanManifestRelativePath(destination) + if err != nil { + return repositoryPackageInclude{}, fmt.Errorf("invalid Agentic Workflow manifest %q: includes destination %q is invalid: %w. Destinations must be repository-root-relative paths without '..' segments. Example:\nincludes:\n - source: payload/workflows/reviewer.md\n destination: %sreviewer.md", manifestPath, destination, err, constants.WorkflowsDirSlash) + } + if err := validateManifestIncludeDestination(cleanedDestination, sourceKind); err != nil { + return repositoryPackageInclude{}, fmt.Errorf("invalid Agentic Workflow manifest %q: includes destination %q is invalid: %w", manifestPath, destination, err) + } + + if kind != "" && kind != sourceKind { + return repositoryPackageInclude{}, fmt.Errorf("invalid Agentic Workflow manifest %q: includes entry declares kind %q but source %q is a %s. Use kind: %s or change the source file extension", manifestPath, kind, source, sourceKind, sourceKind) + } + + return repositoryPackageInclude{ + Source: cleanedSource, + Destination: cleanedDestination, + Kind: sourceKind, + }, nil +} + +// cleanManifestRelativePath normalizes a manifest path and rejects absolute paths and +// paths that escape their root. +func cleanManifestRelativePath(p string) (string, error) { + slashed := filepath.ToSlash(p) + if strings.HasPrefix(slashed, "/") || strings.HasPrefix(slashed, "\\") || filepath.IsAbs(p) || isWindowsDriveRelativePath(slashed) { + return "", errors.New("absolute paths are not allowed") + } + cleaned := path.Clean(slashed) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", errors.New("path traversal outside the root is not allowed") + } + return cleaned, nil +} + +// isWindowsDriveRelativePath reports whether p starts with a Windows drive letter prefix +// (e.g. "C:/payload"). filepath.IsAbs does not detect these on non-Windows hosts. +func isWindowsDriveRelativePath(p string) bool { + if len(p) < 2 || p[1] != ':' { + return false + } + c := p[0] + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +// manifestIncludeKindForPath returns the entry kind implied by the file extension. +func manifestIncludeKindForPath(p string) (string, error) { + lower := strings.ToLower(p) + switch { + case strings.HasSuffix(lower, ".lock.yml"): + return "", errors.New("compiled lock files (.lock.yml) cannot be installed") + case strings.HasSuffix(lower, ".md"): + return manifestIncludeKindAgenticWorkflow, nil + case strings.HasSuffix(lower, ".yml"): + return manifestIncludeKindActionWorkflow, nil + default: + return "", errors.New("only agentic workflows (.md) and action workflows (.yml) are supported") + } +} + +// validateManifestIncludeDestination validates that a mapping destination installs into an +// approved repository namespace with an extension matching its source. +func validateManifestIncludeDestination(destination, sourceKind string) error { + if !strings.HasPrefix(destination, constants.WorkflowsDirSlash) { + return fmt.Errorf("destinations must be under %s", constants.WorkflowsDirSlash) + } + remaining := strings.TrimPrefix(destination, constants.WorkflowsDirSlash) + if remaining == "" || strings.Contains(remaining, "/") { + return fmt.Errorf("destinations must be a direct child of %s", constants.WorkflowsDirSlash) + } + destinationKind, err := manifestIncludeKindForPath(destination) + if err != nil { + return err + } + if destinationKind != sourceKind { + return fmt.Errorf("destination file extension must match the source (%s)", sourceKind) + } + return nil } func extractManifestFiles(value any, manifestPath string) ([]string, []string) { @@ -485,14 +683,18 @@ func formatIncludesCodemodSuggestion(paths []string) string { return strings.Join(lines, "\n") } -func splitManifestIncludePaths(includes []string) (installable, skillDirs, agentFiles []string) { +func splitManifestIncludePaths(includes []repositoryPackageInclude) (installable []repositoryPackageInclude, skillDirs, agentFiles []string) { for _, include := range includes { + if include.isMapping() { + installable = append(installable, include) + continue + } switch { - case isSupportedSkillDirPath(include): - skillDirs = append(skillDirs, include) - case isSupportedAgentFilePath(include): - agentFiles = append(agentFiles, include) - case isSupportedPackageInstallablePath(include): + case isSupportedSkillDirPath(include.Source): + skillDirs = append(skillDirs, include.Source) + case isSupportedAgentFilePath(include.Source): + agentFiles = append(agentFiles, include.Source) + case isSupportedPackageInstallablePath(include.Source): installable = append(installable, include) } } @@ -842,34 +1044,62 @@ func repositoryPackageIdentifier(repoSlug, packagePath string) string { return repoSlug + "/" + packagePath } -func normalizePackageInstallablePaths(paths []string, packagePath string) []string { - normalized := make([]string, 0, len(paths)) +func normalizePackageInstallablePaths(includes []repositoryPackageInclude, packagePath string) []resolvedPackageInstallable { + normalized := make([]resolvedPackageInstallable, 0, len(includes)) seen := make(map[string]struct{}) - for _, path := range paths { - if !isSupportedPackageInstallablePath(path) { - continue - } - // Paths under .github/ are treated as repo-root-relative even in nested - // bundles (e.g. a bundle at "dependabot/" with ".github/workflows/foo.md" - // refers to the repository-root ".github/workflows/foo.md", not to - // "dependabot/.github/workflows/foo.md"). All other paths (e.g. workflows/, - // agentic-workflows/) remain relative to the package root. - if packagePath != "" && strings.HasPrefix(path, constants.GithubDir) { - path = filepath.ToSlash(path) + for _, include := range includes { + sourcePath := include.Source + if include.isMapping() { + // Mapping sources are always package-relative, including for nested packages. + sourcePath = joinRepositoryPackagePath(packagePath, sourcePath) } else { - path = joinRepositoryPackagePath(packagePath, path) + if !isSupportedPackageInstallablePath(sourcePath) { + continue + } + // Paths under .github/ are treated as repo-root-relative even in nested + // bundles (e.g. a bundle at "dependabot/" with ".github/workflows/foo.md" + // refers to the repository-root ".github/workflows/foo.md", not to + // "dependabot/.github/workflows/foo.md"). All other paths (e.g. workflows/, + // agentic-workflows/) remain relative to the package root. + if packagePath != "" && strings.HasPrefix(sourcePath, constants.GithubDir) { + sourcePath = filepath.ToSlash(sourcePath) + } else { + sourcePath = joinRepositoryPackagePath(packagePath, sourcePath) + } } - if _, exists := seen[path]; exists { + if _, exists := seen[sourcePath]; exists { continue } - seen[path] = struct{}{} - normalized = append(normalized, path) + seen[sourcePath] = struct{}{} + destination := include.Destination + if destination == "" { + destination = defaultPackageInstallDestination(sourcePath) + } + normalized = append(normalized, resolvedPackageInstallable{ + SourcePath: sourcePath, + DestinationPath: destination, + }) } return normalized } -func validateManifestInstallableWorkflowPrivacy(manifestPath string, installationSources []string, readWorkflow func(string) ([]byte, error)) error { - for _, installationSource := range installationSources { +// validateUniqueManifestInstallDestinations rejects manifests where two entries would be +// installed to the same repository path, before any file is written. +func validateUniqueManifestInstallDestinations(installables []resolvedPackageInstallable, manifestPath string) error { + seen := make(map[string]string, len(installables)) + for _, installable := range installables { + key := strings.ToLower(installable.DestinationPath) + if previous, exists := seen[key]; exists { + return fmt.Errorf("invalid Agentic Workflow manifest %q: includes entries %q and %q both install to %q. Each entry must have a unique destination; rename one of the destinations", manifestPath, previous, installable.SourcePath, installable.DestinationPath) + } + seen[key] = installable.SourcePath + } + return nil +} + +func validateManifestInstallableWorkflowPrivacy(manifestPath string, installationSources []resolvedPackageInstallable, readWorkflow func(string) ([]byte, error)) error { + for _, installable := range installationSources { + installationSource := installable.SourcePath if isActionWorkflowPath(installationSource) { continue } @@ -981,9 +1211,10 @@ func isSupportedManifestMinVersion(version string) bool { return semverutil.IsActionVersionTag(version) && strings.Count(strings.TrimPrefix(version, "v"), ".") == expectedManifestMinVersionDotCount } -func validateUniqueManifestWorkflowFilenames(paths []string, manifestPath string) error { - seen := make(map[string]string, len(paths)) - for _, installPath := range paths { +func validateUniqueManifestWorkflowFilenames(installables []resolvedPackageInstallable, manifestPath string) error { + seen := make(map[string]string, len(installables)) + for _, installable := range installables { + installPath := installable.DestinationPath if !strings.HasSuffix(strings.ToLower(installPath), ".md") { continue } diff --git a/pkg/cli/add_package_manifest_mapping_test.go b/pkg/cli/add_package_manifest_mapping_test.go new file mode 100644 index 00000000000..7d21b5f3e85 --- /dev/null +++ b/pkg/cli/add_package_manifest_mapping_test.go @@ -0,0 +1,348 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupMappingPackageTest wires the package resolution hooks so that only the manifest and +// README of a package are available, and auto-scan is disabled. +func setupMappingPackageTest(t *testing.T, manifest map[string]string) { + t.Helper() + originalDownload := downloadPackageFileFromGitHubForHost + originalList := listPackageWorkflowFilesForHost + originalDirFiles := listPackageDirFilesForHost + originalDirSubdirs := listPackageDirSubdirsForHost + originalDefaultBranch := getRepositoryPackageDefaultBranch + t.Cleanup(func() { + downloadPackageFileFromGitHubForHost = originalDownload + listPackageWorkflowFilesForHost = originalList + listPackageDirFilesForHost = originalDirFiles + listPackageDirSubdirsForHost = originalDirSubdirs + getRepositoryPackageDefaultBranch = originalDefaultBranch + }) + + getRepositoryPackageDefaultBranch = func(_ context.Context, repoSlug, host string) (string, error) { + return "main", nil + } + listPackageDirFilesForHost = func(_ context.Context, owner, repo, ref, dirPath, host string) ([]string, error) { + return nil, createRepositoryPackageNotFoundError(dirPath) + } + listPackageDirSubdirsForHost = func(_ context.Context, owner, repo, ref, dirPath, host string) ([]string, error) { + return nil, createRepositoryPackageNotFoundError(dirPath) + } + listPackageWorkflowFilesForHost = func(_ context.Context, owner, repo, ref, workflowPath, host string) ([]string, error) { + return nil, createRepositoryPackageNotFoundError(workflowPath) + } + downloadPackageFileFromGitHubForHost = func(_ context.Context, owner, repo, path, ref, host string) ([]byte, error) { + if content, ok := manifest[path]; ok { + return []byte(content), nil + } + return nil, createRepositoryPackageNotFoundError(path) + } +} + +func TestResolveRepositoryPackage_IncludeMappings(t *testing.T) { + t.Run("nested package installs inert sources into .github/workflows", func(t *testing.T) { + setupMappingPackageTest(t, map[string]string{ + "factory/aw.yml": `name: Factory +includes: + - source: payload/workflows/reviewer.md + destination: .github/workflows/reviewer.md + kind: agentic-workflow + - source: payload/workflows/controller.yml + destination: .github/workflows/controller.yml + kind: action-workflow +`, + "factory/README.md": "# Factory\n", + }) + + pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo", PackagePath: "factory"}, "") + require.NoError(t, err) + assert.Equal(t, []resolvedPackageInstallable{ + {SourcePath: "factory/payload/workflows/reviewer.md", DestinationPath: ".github/workflows/reviewer.md"}, + {SourcePath: "factory/payload/workflows/controller.yml", DestinationPath: ".github/workflows/controller.yml"}, + }, pkg.InstallationSource) + }) + + t.Run("mapping source is package-relative even under .github", func(t *testing.T) { + setupMappingPackageTest(t, map[string]string{ + "factory/aw.yml": `name: Factory +includes: + - source: .github/workflows/reviewer.md + destination: .github/workflows/reviewer.md +`, + "factory/README.md": "# Factory\n", + }) + + pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo", PackagePath: "factory"}, "") + require.NoError(t, err) + assert.Equal(t, []string{"factory/.github/workflows/reviewer.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) + }) + + t.Run("mixes legacy string entries with mappings", func(t *testing.T) { + setupMappingPackageTest(t, map[string]string{ + "factory/aw.yml": `name: Factory +includes: + - workflows/review.md + - .github/workflows/legacy.md + - source: payload/workflows/reviewer.md + destination: .github/workflows/reviewer.md +`, + "factory/README.md": "# Factory\n", + }) + + pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo", PackagePath: "factory"}, "") + require.NoError(t, err) + assert.Equal(t, []resolvedPackageInstallable{ + {SourcePath: "factory/workflows/review.md", DestinationPath: ".github/workflows/review.md"}, + {SourcePath: ".github/workflows/legacy.md", DestinationPath: ".github/workflows/legacy.md"}, + {SourcePath: "factory/payload/workflows/reviewer.md", DestinationPath: ".github/workflows/reviewer.md"}, + }, pkg.InstallationSource) + }) + + t.Run("renames the installed workflow to the destination name", func(t *testing.T) { + setupMappingPackageTest(t, map[string]string{ + "aw.yml": `name: Factory +includes: + - source: payload/reviewer.md + destination: .github/workflows/code-reviewer.md +`, + "README.md": "# Factory\n", + }) + + pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") + require.NoError(t, err) + specs := appendRepositoryPackageWorkflowSpecs(nil, &RepoSpec{RepoSlug: "owner/repo"}, pkg) + require.Len(t, specs, 1) + assert.Equal(t, "payload/reviewer.md", specs[0].WorkflowPath) + assert.Equal(t, "code-reviewer", specs[0].WorkflowName) + assert.Equal(t, ".github/workflows/code-reviewer.md", specs[0].DestinationPath) + }) + + invalidCases := []struct { + name string + includes string + contains string + }{ + { + name: "rejects source path traversal", + includes: ` - source: ../../etc/passwd.md + destination: .github/workflows/reviewer.md`, + contains: "path traversal outside the root is not allowed", + }, + { + name: "rejects absolute source", + includes: ` - source: /etc/reviewer.md + destination: .github/workflows/reviewer.md`, + contains: "absolute paths are not allowed", + }, + { + name: "rejects destination path traversal", + includes: ` - source: payload/reviewer.md + destination: ../../.github/workflows/reviewer.md`, + contains: "path traversal outside the root is not allowed", + }, + { + name: "rejects absolute destination", + includes: ` - source: payload/reviewer.md + destination: /tmp/reviewer.md`, + contains: "absolute paths are not allowed", + }, + { + name: "rejects destination outside .github/workflows", + includes: ` - source: payload/reviewer.md + destination: .github/agents/reviewer.md`, + contains: "destinations must be under .github/workflows/", + }, + { + name: "rejects nested destination", + includes: ` - source: payload/reviewer.md + destination: .github/workflows/nested/reviewer.md`, + contains: "destinations must be a direct child of .github/workflows/", + }, + { + name: "rejects extension mismatch", + includes: ` - source: payload/reviewer.md + destination: .github/workflows/reviewer.yml`, + contains: "destination file extension must match the source", + }, + { + name: "rejects unsupported extension", + includes: ` - source: payload/reviewer.txt + destination: .github/workflows/reviewer.txt`, + contains: "only agentic workflows (.md) and action workflows (.yml) are supported", + }, + { + name: "rejects lock files", + includes: ` - source: payload/reviewer.lock.yml + destination: .github/workflows/reviewer.lock.yml`, + contains: "compiled lock files (.lock.yml) cannot be installed", + }, + { + name: "rejects mismatched kind", + includes: ` - source: payload/reviewer.md + destination: .github/workflows/reviewer.md + kind: action-workflow`, + contains: "declares kind \"action-workflow\"", + }, + { + name: "rejects duplicate destinations", + includes: ` - source: payload/a/controller.yml + destination: .github/workflows/controller.yml + - source: payload/b/other.yml + destination: .github/workflows/controller.yml`, + contains: "both install to \".github/workflows/controller.yml\"", + }, + } + + t.Run("rejects duplicate markdown destinations", func(t *testing.T) { + setupMappingPackageTest(t, map[string]string{ + "factory/aw.yml": `name: Factory +includes: + - source: payload/a/reviewer.md + destination: .github/workflows/reviewer.md + - source: payload/b/other.md + destination: .github/workflows/reviewer.md +`, + "factory/README.md": "# Factory\n", + }) + + _, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo", PackagePath: "factory"}, "") + require.Error(t, err) + assert.ErrorContains(t, err, "duplicate workflow filename") + }) + + for _, tt := range invalidCases { + t.Run(tt.name, func(t *testing.T) { + setupMappingPackageTest(t, map[string]string{ + "factory/aw.yml": "name: Factory\nincludes:\n" + tt.includes + "\n", + "factory/README.md": "# Factory\n", + }) + + _, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo", PackagePath: "factory"}, "") + require.Error(t, err) + assert.ErrorContains(t, err, tt.contains) + }) + } +} + +func TestManifestWorkflowPathByName_UsesDestination(t *testing.T) { + t.Parallel() + byName := manifestWorkflowPathByName([]resolvedPackageInstallable{ + {SourcePath: "factory/payload/workflows/reviewer.md", DestinationPath: ".github/workflows/code-reviewer.md"}, + {SourcePath: "factory/payload/workflows/controller.yml", DestinationPath: ".github/workflows/controller.yml"}, + }) + assert.Equal(t, map[string]string{ + "code-reviewer": "factory/payload/workflows/reviewer.md", + }, byName) +} + +func TestResolveLocalRepositoryPackage_IncludeMappings(t *testing.T) { + writePackage := func(t *testing.T, manifest string) string { + t.Helper() + packageDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "aw.yml"), []byte(manifest), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "README.md"), []byte("# Factory\n"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(packageDir, "payload", "workflows"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "payload", "workflows", "reviewer.md"), []byte("# Reviewer\n"), 0o600)) + return packageDir + } + + t.Run("resolves mapping entries", func(t *testing.T) { + packageDir := writePackage(t, `name: Factory +includes: + - source: payload/workflows/reviewer.md + destination: .github/workflows/reviewer.md +`) + pkg, err := resolveLocalRepositoryPackage(packageDir) + require.NoError(t, err) + require.Len(t, pkg.InstallationSource, 1) + assert.Equal(t, filepath.Join(packageDir, "payload", "workflows", "reviewer.md"), pkg.InstallationSource[0].SourcePath) + assert.Equal(t, ".github/workflows/reviewer.md", pkg.InstallationSource[0].DestinationPath) + }) + + t.Run("rejects symlinked mapping sources", func(t *testing.T) { + packageDir := writePackage(t, `name: Factory +includes: + - source: payload/workflows/linked.md + destination: .github/workflows/linked.md +`) + target := filepath.Join(packageDir, "payload", "workflows", "reviewer.md") + link := filepath.Join(packageDir, "payload", "workflows", "linked.md") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks are not supported on this platform: %v", err) + } + + _, err := resolveLocalRepositoryPackage(packageDir) + require.Error(t, err) + assert.ErrorContains(t, err, "is a symbolic link") + }) + + t.Run("rejects missing mapping sources", func(t *testing.T) { + packageDir := writePackage(t, `name: Factory +includes: + - source: payload/workflows/missing.md + destination: .github/workflows/missing.md +`) + _, err := resolveLocalRepositoryPackage(packageDir) + require.Error(t, err) + assert.ErrorContains(t, err, "missing.md") + }) +} + +// TestAddWorkflowsWithTracking_InstallsMappedDestinations verifies that both agentic +// markdown and deterministic action workflows declared through source-to-destination +// mappings are installed under their declared destination file names. This is the shared +// installation path used by both `gh aw add` and `gh aw add-wizard`. +func TestAddWorkflowsWithTracking_InstallsMappedDestinations(t *testing.T) { + tempDir := testutil.TempDir(t, "test-add-mapped-destination-*") + workflowsDir := setupMinimalGitRepo(t, tempDir) + + workflows := []*ResolvedWorkflow{ + { + Spec: &WorkflowSpec{ + WorkflowPath: "factory/payload/workflows/reviewer.md", + WorkflowName: "reviewer", + DestinationPath: ".github/workflows/code-reviewer.md", + FromRepositoryManifest: true, + }, + Content: []byte("---\non: workflow_dispatch\nengine: claude\npermissions: read-all\n---\n\n# Reviewer\n"), + SourceInfo: &FetchedWorkflow{IsLocal: true}, + }, + { + Spec: &WorkflowSpec{ + WorkflowPath: "factory/payload/workflows/controller.yml", + WorkflowName: "controller", + DestinationPath: ".github/workflows/deterministic-controller.yml", + FromRepositoryManifest: true, + }, + Content: []byte("name: Controller\non: workflow_dispatch\njobs: {}\n"), + IsActionWorkflow: true, + SourceInfo: &FetchedWorkflow{IsLocal: true}, + }, + } + + err := addWorkflowsWithTracking(context.Background(), workflows, NewFileTracker(), AddOptions{ + NoGitattributes: true, + DisableSecurityScanner: true, + Quiet: true, + }) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(workflowsDir, "code-reviewer.md")) + assert.FileExists(t, filepath.Join(workflowsDir, "deterministic-controller.yml")) + assert.NoFileExists(t, filepath.Join(workflowsDir, "reviewer.md")) + assert.NoFileExists(t, filepath.Join(workflowsDir, "controller.yml")) + + // Deterministic action workflows are copied verbatim. + written, err := os.ReadFile(filepath.Join(workflowsDir, "deterministic-controller.yml")) + require.NoError(t, err) + assert.Equal(t, "name: Controller\non: workflow_dispatch\njobs: {}\n", string(written)) +} diff --git a/pkg/cli/add_package_manifest_test.go b/pkg/cli/add_package_manifest_test.go index 2b6fe69d24d..1b713174b87 100644 --- a/pkg/cli/add_package_manifest_test.go +++ b/pkg/cli/add_package_manifest_test.go @@ -80,7 +80,7 @@ files: assert.Equal(t, "๐Ÿค–", pkg.Emoji) assert.Equal(t, "MIT", pkg.License) assert.Equal(t, "README.md", pkg.DocsPath) - assert.Equal(t, []string{"workflows/review.md", ".github/workflows/nightly-review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md", ".github/workflows/nightly-review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) require.NotEmpty(t, pkg.Warnings) assert.Contains(t, strings.Join(pkg.Warnings, "\n"), "Ignoring files entry") }) @@ -127,7 +127,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "github.com") require.NoError(t, err) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("uses latest release for github/gh-aw when version is omitted", func(t *testing.T) { @@ -172,7 +172,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "github/gh-aw"}, "github.com") require.NoError(t, err) assert.Equal(t, "v1.2.3", pkg.ResolvedRef) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("falls back to default branch for github/gh-aw when latest release lookup fails", func(t *testing.T) { @@ -218,7 +218,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "github/gh-aw"}, "github.com") require.NoError(t, err) assert.Equal(t, "main", pkg.ResolvedRef) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("uses slash branch ref from manifest route", func(t *testing.T) { @@ -252,7 +252,7 @@ files: Version: "feature/github-agentic-workflow", }, "github.com") require.NoError(t, err) - assert.Equal(t, []string{"agentic-workflows/workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"agentic-workflows/workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("falls back to scanning supported workflow directories", func(t *testing.T) { @@ -281,7 +281,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") require.NoError(t, err) assert.Equal(t, "README.md", pkg.DocsPath) - assert.Equal(t, []string{"workflows/review.md", ".github/workflows/nightly-review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md", ".github/workflows/nightly-review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("passes explicit host to scanning fallback", func(t *testing.T) { @@ -311,7 +311,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "github.com") require.NoError(t, err) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("rejects manifest without name field", func(t *testing.T) { @@ -374,7 +374,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") require.NoError(t, err) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("accepts git describe compiler version for matching min-version", func(t *testing.T) { @@ -405,7 +405,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") require.NoError(t, err) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("accepts manifest without manifest-version", func(t *testing.T) { @@ -429,7 +429,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") require.NoError(t, err) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("rejects unsupported manifest-version", func(t *testing.T) { @@ -699,7 +699,7 @@ files: require.NoError(t, err) assert.Equal(t, "packages/repo-assist/aw.yml", pkg.ManifestPath) assert.Equal(t, "packages/repo-assist/README.md", pkg.DocsPath) - assert.Equal(t, []string{"packages/repo-assist/workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"packages/repo-assist/workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) }) t.Run("nested bundle github workflows paths are repo-root-relative", func(t *testing.T) { @@ -731,7 +731,7 @@ files: assert.Equal(t, []string{ "dependabot/workflows/review.md", ".github/workflows/dependabot-orchestrator.md", - }, pkg.InstallationSource) + }, packageInstallableSourcePaths(pkg.InstallationSource)) }) } @@ -1204,20 +1204,21 @@ func TestIsSupportedPackageInstallablePath(t *testing.T) { func TestExtractManifestIncludes(t *testing.T) { t.Parallel() - includes, warnings := extractManifestIncludes([]any{ + includes, warnings, err := extractManifestIncludes([]any{ "workflows/review.md", "agentic-workflows/review.md", "skills/code-review", "agents/reviewer.md", ".github/workflows/ci.yml", }, "aw.yml") + require.NoError(t, err) assert.Equal(t, []string{ "workflows/review.md", "agentic-workflows/review.md", "skills/code-review", "agents/reviewer.md", ".github/workflows/ci.yml", - }, includes) + }, manifestIncludeSources(includes)) assert.Empty(t, warnings) } @@ -1281,7 +1282,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") require.NoError(t, err) - assert.Equal(t, []string{"workflows/triage.md", ".github/workflows/ci.yml"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/triage.md", ".github/workflows/ci.yml"}, packageInstallableSourcePaths(pkg.InstallationSource)) assert.Contains(t, strings.Join(pkg.Warnings, "\n"), "Field 'files'") }) @@ -1308,7 +1309,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") require.NoError(t, err) // Only the .md file should be accepted; the yml under workflows/ is rejected - assert.Equal(t, []string{"workflows/triage.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/triage.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) require.NotEmpty(t, pkg.Warnings) assert.Contains(t, strings.Join(pkg.Warnings, "\n"), "Ignoring files entry") }) @@ -1600,7 +1601,7 @@ files: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") require.NoError(t, err) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) require.Len(t, pkg.SkillFiles, 2) assert.Equal(t, "skills/code-review/SKILL.md", pkg.SkillFiles[0].SourcePath) assert.Equal(t, "code-review", pkg.SkillFiles[0].SkillName) @@ -1645,7 +1646,7 @@ includes: pkg, err := resolveRepositoryPackage(t.Context(), &RepoSpec{RepoSlug: "owner/repo"}, "") require.NoError(t, err) - assert.Equal(t, []string{"workflows/review.md"}, pkg.InstallationSource) + assert.Equal(t, []string{"workflows/review.md"}, packageInstallableSourcePaths(pkg.InstallationSource)) require.Len(t, pkg.SkillFiles, 2) assert.Equal(t, []string{".github/agents/triage.md"}, pkg.AgentFiles) }) @@ -2239,3 +2240,12 @@ func TestPrintBootstrapConfigTODO(t *testing.T) { assert.NotContains(t, out, "gh aw bootstrap") }) } + +// manifestIncludeSources returns the source path of each manifest include entry. +func manifestIncludeSources(includes []repositoryPackageInclude) []string { + sources := make([]string, 0, len(includes)) + for _, include := range includes { + sources = append(sources, include.Source) + } + return sources +} diff --git a/pkg/cli/add_workflow_resolution.go b/pkg/cli/add_workflow_resolution.go index 900d357f3ba..125cda606b0 100644 --- a/pkg/cli/add_workflow_resolution.go +++ b/pkg/cli/add_workflow_resolution.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path" "path/filepath" "strings" @@ -463,17 +464,24 @@ func resolveLocalRepositoryPackage(source string) (*resolvedRepositoryPackage, e } includeInstallablePaths, includeSkillDirs, includeAgentFiles := splitManifestIncludePaths(manifest.Includes) - includeInstallablePaths = append(includeInstallablePaths, manifest.Files...) - installationSources := normalizeLocalPackageInstallablePaths(includeInstallablePaths, packageDir) + includeInstallablePaths = append(includeInstallablePaths, manifestIncludesFromPaths(manifest.Files)...) + installationSources, err := normalizeLocalPackageInstallablePaths(includeInstallablePaths, packageDir) + if err != nil { + return nil, err + } if len(installationSources) == 0 { - installationSources, err = scanLocalRepositoryPackageInstallablePaths(packageDir) + scanned, err := scanLocalRepositoryPackageInstallablePaths(packageDir) if err != nil { return nil, err } + installationSources = packageInstallablesFromSourcePaths(scanned) } if err := validateUniqueManifestWorkflowFilenames(installationSources, manifestPath); err != nil { return nil, err } + if err := validateUniqueManifestInstallDestinations(installationSources, manifestPath); err != nil { + return nil, err + } skillFiles, skillWarnings, err := resolveLocalPackageSkillFiles(packageDir, append(append([]string{}, manifest.Skills...), includeSkillDirs...)) if err != nil { @@ -532,34 +540,75 @@ func localRepositoryPackageManifest(source string) (string, string, error) { return resolvedPath, filepath.Dir(resolvedPath), nil } -func normalizeLocalPackageInstallablePaths(paths []string, packageDir string) []string { - normalized := make([]string, 0, len(paths)) +func normalizeLocalPackageInstallablePaths(includes []repositoryPackageInclude, packageDir string) ([]resolvedPackageInstallable, error) { + normalized := make([]resolvedPackageInstallable, 0, len(includes)) seen := make(map[string]struct{}) - for _, sourcePath := range paths { - if !isSupportedPackageInstallablePath(sourcePath) { + for _, include := range includes { + if !include.isMapping() && !isSupportedPackageInstallablePath(include.Source) { continue } - absolutePath := filepath.Join(packageDir, filepath.FromSlash(sourcePath)) - absolutePath = filepath.Clean(absolutePath) + absolutePath := filepath.Clean(filepath.Join(packageDir, filepath.FromSlash(include.Source))) + if include.isMapping() { + if err := validateLocalPackageMappingSource(absolutePath, packageDir, include.Source); err != nil { + return nil, err + } + } if _, exists := seen[absolutePath]; exists { continue } seen[absolutePath] = struct{}{} - normalized = append(normalized, absolutePath) + destination := include.Destination + if destination == "" { + destination = defaultPackageInstallDestination(absolutePath) + } + normalized = append(normalized, resolvedPackageInstallable{ + SourcePath: absolutePath, + DestinationPath: destination, + }) + } + return normalized, nil +} + +// validateLocalPackageMappingSource rejects mapping sources that resolve outside the +// package directory or that are symlinks. +func validateLocalPackageMappingSource(absolutePath, packageDir, source string) error { + cleanedPackageDir := filepath.Clean(packageDir) + if absolutePath != cleanedPackageDir && !strings.HasPrefix(absolutePath, cleanedPackageDir+string(os.PathSeparator)) { + return fmt.Errorf("invalid Agentic Workflow manifest in %q: includes source %q resolves outside the package directory", packageDir, source) } - return normalized + info, err := os.Lstat(absolutePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("invalid Agentic Workflow manifest in %q: includes source %q does not exist", packageDir, source) + } + return fmt.Errorf("failed to inspect includes source %q: %w", source, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("invalid Agentic Workflow manifest in %q: includes source %q is a symbolic link, which is not allowed", packageDir, source) + } + if info.IsDir() { + return fmt.Errorf("invalid Agentic Workflow manifest in %q: includes source %q is a directory, but a file is required", packageDir, source) + } + return nil +} + +// packageInstallableWorkflowName returns the workflow name used when installing a package +// entry. The name is derived from the install destination so that source-to-destination +// mappings install under their declared destination file name. +func packageInstallableWorkflowName(installable resolvedPackageInstallable) string { + base := path.Base(filepath.ToSlash(installable.DestinationPath)) + return strings.TrimSuffix(base, path.Ext(base)) } func appendLocalRepositoryPackageWorkflowSpecs(parsedSpecs []*WorkflowSpec, pkg *resolvedRepositoryPackage) []*WorkflowSpec { if pkg == nil { return parsedSpecs } - for _, installationSource := range pkg.InstallationSource { - base := filepath.Base(installationSource) - workflowName := strings.TrimSuffix(base, filepath.Ext(base)) + for _, installable := range pkg.InstallationSource { parsedSpecs = append(parsedSpecs, &WorkflowSpec{ - WorkflowPath: installationSource, - WorkflowName: workflowName, + WorkflowPath: installable.SourcePath, + WorkflowName: packageInstallableWorkflowName(installable), + DestinationPath: installable.DestinationPath, FromRepositoryManifest: true, }) } @@ -711,21 +760,20 @@ func appendRepositoryPackageWorkflowSpecs(parsedSpecs []*WorkflowSpec, repoSpec } host := explicitHostForRepo(repoSpec.RepoSlug) effectiveVersion := repositoryPackageEffectiveRef(repoSpec, pkg) - for _, installationSource := range pkg.InstallationSource { - // installationSource is guaranteed by isSupportedPackageInstallablePath to be - // either a .md agentic workflow or a .yml action workflow file; no other - // extensions can reach this point. - base := filepath.Base(installationSource) - // Use filepath.Ext for case-insensitive extension removal (e.g. ".YML" or ".MD"). - workflowName := strings.TrimSuffix(base, filepath.Ext(base)) + for _, installable := range pkg.InstallationSource { + // Each installable is guaranteed to be either a .md agentic workflow or a .yml + // action workflow file; no other extensions can reach this point. The workflow + // name is derived from the install destination so that source-to-destination + // mappings install under their declared destination name. parsedSpecs = append(parsedSpecs, &WorkflowSpec{ RepoSpec: RepoSpec{ RepoSlug: repoSpec.RepoSlug, Version: effectiveVersion, PackagePath: repoSpec.PackagePath, }, - WorkflowPath: installationSource, - WorkflowName: workflowName, + WorkflowPath: installable.SourcePath, + WorkflowName: packageInstallableWorkflowName(installable), + DestinationPath: installable.DestinationPath, Host: host, FromRepositoryManifest: true, }) diff --git a/pkg/cli/add_workflow_resolution_manifest_ref_test.go b/pkg/cli/add_workflow_resolution_manifest_ref_test.go index 679a6745e8d..7486f51f88e 100644 --- a/pkg/cli/add_workflow_resolution_manifest_ref_test.go +++ b/pkg/cli/add_workflow_resolution_manifest_ref_test.go @@ -11,7 +11,7 @@ func TestAppendRepositoryPackageWorkflowSpecs_PropagatesResolvedRef(t *testing.T } pkg := &resolvedRepositoryPackage{ ResolvedRef: "v1.2.3", - InstallationSource: []string{"workflows/review.md"}, + InstallationSource: packageInstallablesFromSourcePaths([]string{"workflows/review.md"}), SkillFiles: []resolvedPackageSkillFile{ {SourcePath: "skills/review/SKILL.md", SkillName: "review"}, }, @@ -37,7 +37,7 @@ func TestAppendRepositoryPackageWorkflowSpecs_PrefersExplicitVersion(t *testing. } pkg := &resolvedRepositoryPackage{ ResolvedRef: "v1.2.3", - InstallationSource: []string{"workflows/review.md"}, + InstallationSource: packageInstallablesFromSourcePaths([]string{"workflows/review.md"}), } specs := appendRepositoryPackageWorkflowSpecs(nil, repoSpec, pkg) diff --git a/pkg/cli/compile_repository_manifest.go b/pkg/cli/compile_repository_manifest.go index 1be4cbf962f..bafc91c4da8 100644 --- a/pkg/cli/compile_repository_manifest.go +++ b/pkg/cli/compile_repository_manifest.go @@ -113,13 +113,14 @@ func validateLocalRepositoryPackageContents(manifestPath string) error { } includeInstallablePaths, _, _ := splitManifestIncludePaths(manifest.Includes) - includeInstallablePaths = append(includeInstallablePaths, manifest.Files...) + includeInstallablePaths = append(includeInstallablePaths, manifestIncludesFromPaths(manifest.Files)...) installationSources := normalizePackageInstallablePaths(includeInstallablePaths, "") if len(installationSources) == 0 { - installationSources, err = scanLocalRepositoryPackageInstallablePaths(filepath.Dir(manifestPath)) + scanned, err := scanLocalRepositoryPackageInstallablePaths(filepath.Dir(manifestPath)) if err != nil { return err } + installationSources = packageInstallablesFromSourcePaths(scanned) } return validateManifestInstallableWorkflowPrivacy(manifestPath, installationSources, func(sourcePath string) ([]byte, error) { diff --git a/pkg/cli/spec.go b/pkg/cli/spec.go index 2caa89e3802..f30248d1584 100644 --- a/pkg/cli/spec.go +++ b/pkg/cli/spec.go @@ -50,6 +50,10 @@ type WorkflowSpec struct { // IsPackageAgentFile is true when this spec refers to an agent .md file from an aw.yml // package manifest. The file is installed as-is to the agentic engine agents folder. IsPackageAgentFile bool + // DestinationPath is the repository-root-relative install path declared by a package + // manifest source-to-destination mapping (e.g. ".github/workflows/reviewer.md"). + // Empty when the install path is derived from the source file name. + DestinationPath string // SkillName is the name of the skill directory for package skill files. // Only meaningful when IsPackageSkillFile is true. SkillName string diff --git a/pkg/cli/update_manifest.go b/pkg/cli/update_manifest.go index 4419427f491..6322cb41f2b 100644 --- a/pkg/cli/update_manifest.go +++ b/pkg/cli/update_manifest.go @@ -47,14 +47,16 @@ func manifestSourceWithRef(repoSpec *RepoSpec, ref string) string { return base + "@" + ref } -func manifestWorkflowPathByName(paths []string) map[string]string { - byName := make(map[string]string, len(paths)) - for _, p := range paths { - if !strings.HasSuffix(strings.ToLower(p), ".md") { +// manifestWorkflowPathByName maps the installed workflow name (derived from the install +// destination) to the package source path used to re-fetch the workflow. +func manifestWorkflowPathByName(installables []resolvedPackageInstallable) map[string]string { + byName := make(map[string]string, len(installables)) + for _, installable := range installables { + if !strings.HasSuffix(strings.ToLower(installable.DestinationPath), ".md") { continue } - workflowID := normalizeWorkflowID(filepath.Base(p)) - byName[workflowID] = p + workflowID := normalizeWorkflowID(filepath.Base(installable.DestinationPath)) + byName[workflowID] = installable.SourcePath } return byName } diff --git a/pkg/parser/schemas/aw_manifest_schema.json b/pkg/parser/schemas/aw_manifest_schema.json index af4835bd30c..e133211d35a 100644 --- a/pkg/parser/schemas/aw_manifest_schema.json +++ b/pkg/parser/schemas/aw_manifest_schema.json @@ -28,9 +28,36 @@ }, "includes": { "type": "array", - "description": "Installable package entries. Use folder naming conventions to infer type: workflows under workflows/, agentic-workflows/, or .github/workflows/; skill directories under skills/ or .github/skills/; agent files under agents/ or .github/agents/.", + "description": "Installable package entries. String entries use folder naming conventions to infer type: workflows under workflows/, agentic-workflows/, or .github/workflows/; skill directories under skills/ or .github/skills/; agent files under agents/ or .github/agents/. Object entries map a package-relative 'source' to a repository-root-relative 'destination' under .github/workflows/.", "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "description": "Source-to-destination mapping. 'source' is resolved relative to the package root (including nested packages); 'destination' is resolved relative to the consuming repository root.", + "required": ["source", "destination"], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "minLength": 1, + "description": "Package-relative path of the file to install (.md agentic workflow or .yml action workflow)." + }, + "destination": { + "type": "string", + "minLength": 1, + "description": "Repository-root-relative install path. Must be a direct child of .github/workflows/." + }, + "kind": { + "type": "string", + "enum": ["agentic-workflow", "action-workflow"], + "description": "Optional explicit entry kind. Must match the file extension: agentic-workflow for .md, action-workflow for .yml." + } + } + } + ] } }, "files": { From 083cd9af1ca8ab7288065b18a267016e2bbf3566 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:52:47 +0000 Subject: [PATCH 3/5] Derive install name from destination without redundant branch Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/add_command.go | 8 ++------ pkg/cli/add_package_manifest_mapping_test.go | 21 ++++++++++++++------ pkg/cli/spec.go | 7 ++++--- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index 6474faf3eb3..2f05e205fb6 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "path" "path/filepath" "strings" @@ -388,14 +387,11 @@ func addWorkflowWithTracking(ctx context.Context, resolved *ResolvedWorkflow, tr return err } + // For package manifest entries WorkflowName is derived from the entry's install + // destination, so mapped entries install under their declared destination name. workflowName := workflowSpec.WorkflowName if opts.Name != "" { workflowName = opts.Name - } else if workflowSpec.DestinationPath != "" { - // Package manifests may map a package-relative source to an explicit - // repository-root-relative destination under .github/workflows/. - base := path.Base(workflowSpec.DestinationPath) - workflowName = strings.TrimSuffix(base, path.Ext(base)) } // Action workflow files (.yml) are copied as-is to .github/workflows/ without any // frontmatter processing, dependency fetching, or compilation. diff --git a/pkg/cli/add_package_manifest_mapping_test.go b/pkg/cli/add_package_manifest_mapping_test.go index 7d21b5f3e85..b07ce938ca8 100644 --- a/pkg/cli/add_package_manifest_mapping_test.go +++ b/pkg/cli/add_package_manifest_mapping_test.go @@ -305,12 +305,21 @@ func TestAddWorkflowsWithTracking_InstallsMappedDestinations(t *testing.T) { tempDir := testutil.TempDir(t, "test-add-mapped-destination-*") workflowsDir := setupMinimalGitRepo(t, tempDir) + markdownEntry := resolvedPackageInstallable{ + SourcePath: "factory/payload/workflows/reviewer.md", + DestinationPath: ".github/workflows/code-reviewer.md", + } + actionEntry := resolvedPackageInstallable{ + SourcePath: "factory/payload/workflows/controller.yml", + DestinationPath: ".github/workflows/deterministic-controller.yml", + } + workflows := []*ResolvedWorkflow{ { Spec: &WorkflowSpec{ - WorkflowPath: "factory/payload/workflows/reviewer.md", - WorkflowName: "reviewer", - DestinationPath: ".github/workflows/code-reviewer.md", + WorkflowPath: markdownEntry.SourcePath, + WorkflowName: packageInstallableWorkflowName(markdownEntry), + DestinationPath: markdownEntry.DestinationPath, FromRepositoryManifest: true, }, Content: []byte("---\non: workflow_dispatch\nengine: claude\npermissions: read-all\n---\n\n# Reviewer\n"), @@ -318,9 +327,9 @@ func TestAddWorkflowsWithTracking_InstallsMappedDestinations(t *testing.T) { }, { Spec: &WorkflowSpec{ - WorkflowPath: "factory/payload/workflows/controller.yml", - WorkflowName: "controller", - DestinationPath: ".github/workflows/deterministic-controller.yml", + WorkflowPath: actionEntry.SourcePath, + WorkflowName: packageInstallableWorkflowName(actionEntry), + DestinationPath: actionEntry.DestinationPath, FromRepositoryManifest: true, }, Content: []byte("name: Controller\non: workflow_dispatch\njobs: {}\n"), diff --git a/pkg/cli/spec.go b/pkg/cli/spec.go index f30248d1584..49775ef7045 100644 --- a/pkg/cli/spec.go +++ b/pkg/cli/spec.go @@ -50,9 +50,10 @@ type WorkflowSpec struct { // IsPackageAgentFile is true when this spec refers to an agent .md file from an aw.yml // package manifest. The file is installed as-is to the agentic engine agents folder. IsPackageAgentFile bool - // DestinationPath is the repository-root-relative install path declared by a package - // manifest source-to-destination mapping (e.g. ".github/workflows/reviewer.md"). - // Empty when the install path is derived from the source file name. + // DestinationPath is the repository-root-relative install path resolved from a package + // manifest entry (e.g. ".github/workflows/reviewer.md"). WorkflowName is derived from + // it, so installation writes the file under the declared destination name. + // Empty for specs that do not originate from a package manifest entry. DestinationPath string // SkillName is the name of the skill directory for package skill files. // Only meaningful when IsPackageSkillFile is true. From 0a4849bbf068bdc456174a614cf5ebb4eb116c7b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:48:08 +0000 Subject: [PATCH 4/5] Add draft ADR-53698: source-to-destination mappings in aw.yml includes Documents the architectural decision to extend the `includes` manifest field with object-form entries that pair a package-relative source path with a consumer-repository-root-relative destination path. --- ...rce-to-destination-mappings-in-includes.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/adr/53698-source-to-destination-mappings-in-includes.md diff --git a/docs/adr/53698-source-to-destination-mappings-in-includes.md b/docs/adr/53698-source-to-destination-mappings-in-includes.md new file mode 100644 index 00000000000..51734bc4426 --- /dev/null +++ b/docs/adr/53698-source-to-destination-mappings-in-includes.md @@ -0,0 +1,47 @@ +# ADR-53698: Source-to-Destination Mappings in `aw.yml` `includes` + +**Date**: 2026-08-18 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +The `aw.yml` package manifest's `includes` field previously accepted only path strings. String entries beginning with `.github/` are resolved relative to the consuming repository root (a rule preserved for backward compatibility). This made it impossible for a distribution repository to keep workflow assets outside its own `.github/workflows/` directory โ€” because any path declared as `.github/workflows/foo.md` pointed to the consumer's repository root โ€” while also preventing assets stored outside `.github/` in the distribution repository from being installed into the consumer's `.github/workflows/`. Package maintainers had no mechanism to declare separate source and destination paths, so assets that should not run in the distribution repository were forced into `.github/workflows/`, where the GitHub Actions runtime would execute them. Issue #52770 filed this limitation explicitly. + +### Decision + +We will extend the `includes` array to accept object entries (source-to-destination mappings) with required `source` (package-relative path) and `destination` (consuming-repository-root-relative path) fields, plus an optional `kind` discriminator (`agentic-workflow` | `action-workflow`). Mapping sources are always resolved relative to the package root regardless of their prefix; the `.github/` special case that applies to string entries does not apply. Destinations are restricted to direct children of `.github/workflows/`. All three install commands (`gh aw add`, `gh aw add-wizard`, `gh aw update`) share the same mapping semantics through a unified `resolvedPackageInstallable` struct. Validation failures for mapping entries are hard errors that abort before any file is written. + +### Alternatives Considered + +#### Alternative 1: Change resolution rules for `.github/**` string entries in nested packages + +Modify the existing special case so that `.github/**` string entries in a nested package are resolved package-relative rather than repository-root-relative. This would allow `factory/aw.yml` to declare `.github/workflows/foo.md` and have it resolve to `factory/.github/workflows/foo.md`. This was rejected because it breaks backward compatibility: existing manifests depend on the repository-root-relative behavior, and that behavior is explicitly documented (and recorded in ADR-41790). + +#### Alternative 2: Add a separate top-level `mappings` field to `aw.yml` + +Introduce a new `mappings` key alongside `includes` and `files`. This was rejected because it fragments the authoring surface further โ€” `files` is already deprecated in favor of `includes` โ€” and it prevents authors from mixing string entries and mappings in a single ordered list. Extending `includes` with a union type (string | object) keeps the manifest surface minimal and preserves entry ordering. + +### Consequences + +#### Positive +- Distribution repositories can now keep workflow assets outside `.github/workflows/`, preventing accidental execution in the distribution context, while still installing those assets into the consumer's `.github/workflows/`. +- Source file names are decoupled from install names: a file at `payload/workflows/reviewer.md` can be installed as `.github/workflows/code-reviewer.md`. +- All existing string-entry semantics are unchanged; existing manifests continue to work without modification. +- Semantic validation (absolute paths, path traversal, symlinks, extension mismatches, unsupported extensions, duplicate destinations) is applied eagerly and fails before any file is written. + +#### Negative +- The internal model evolves from plain `[]string` to `[]resolvedPackageInstallable` structs, requiring updates across all callers including tests โ€” a broad but mechanical change. +- `extractManifestIncludes` now returns an error in addition to warnings (compared to the previous warnings-only return), changing the call signature at all call sites. +- Validation failures for mapping entries are hard errors (not soft skips), which is a stricter behavior than the existing warning-and-continue approach for unsupported string entries. + +#### Neutral +- The `aw_manifest_schema.json` JSON Schema gains a `oneOf` branch to accommodate object entries alongside string entries. +- `gh aw update` keys installed workflows by destination name and maps them back to source paths, so manifest-scoped source tracking is preserved without additional changes. +- `files` is now marked deprecated in the reference documentation and specification (ยง4.8); string-form `files` entries continue to work via conversion to `repositoryPackageInclude{Source: p}`. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 04706c82d3d43996f1aff3dda91266365caddbb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:34:34 +0000 Subject: [PATCH 5/5] Add missing !integration build tag to mapping test file Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/add_package_manifest_mapping_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cli/add_package_manifest_mapping_test.go b/pkg/cli/add_package_manifest_mapping_test.go index b07ce938ca8..425f9d3f22b 100644 --- a/pkg/cli/add_package_manifest_mapping_test.go +++ b/pkg/cli/add_package_manifest_mapping_test.go @@ -1,3 +1,5 @@ +//go:build !integration + package cli import (