Skip to content

Commit f414fc7

Browse files
authored
fix: treat .github/ paths as repo-root-relative in nested bundle manifests (#41790)
1 parent cc4e030 commit f414fc7

3 files changed

Lines changed: 217 additions & 2 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# ADR-41790: Treat `.github/` Paths as Repo-Root-Relative in Nested Bundle Manifests
2+
3+
**Date**: 2026-06-26
4+
**Status**: Draft
5+
**Deciders**: Unknown (Copilot-authored fix; see PR #41790)
6+
7+
---
8+
9+
### Context
10+
11+
The gh-aw tooling supports "nested bundles" — agentic workflow manifests (`aw.yml`) stored at a sub-path within a repository (e.g., `dependabot/aw.yml`). Bundle manifests can enumerate workflow files under a `files:` key, including paths under `.github/workflows/`. Prior to this fix, the path normalization functions `normalizePackageInstallablePaths` and `scanRepositoryPackageInstallablePaths` unconditionally prepended the bundle's package path to every entry, causing `.github/workflows/foo.md` to resolve to `dependabot/.github/workflows/foo.md` — a path that does not exist, producing HTTP 404 errors at install time. The `.github/` directory is a GitHub-defined repository-root convention; no sub-path equivalent exists.
12+
13+
### Decision
14+
15+
We will treat any path beginning with `.github/` in a bundle manifest as repo-root-relative, bypassing package-prefix logic entirely. All other supported path prefixes (`workflows/`, `agentic-workflows/`) remain relative to the bundle's package root and continue to receive the package-path prefix. This convention is encoded in both `normalizePackageInstallablePaths` (explicit `files:` entries) and `scanRepositoryPackageInstallablePaths` (auto-scan), so the rule applies uniformly regardless of how paths reach the resolver.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Require manifest authors to always use full repo-root paths
20+
21+
Manifest authors could be required to write `.github/workflows/foo.md` without any special handling — the same path would be expected from both root-level and nested bundles, and the normalization code would be left unchanged. This was rejected because the current normalization logic would silently corrupt any such path by prepending the package directory, and because fixing that would require all existing nested manifests to be audited and updated, with no compiler-enforced guarantee of correctness.
22+
23+
#### Alternative 2: Refactor path representation to store full repo-root paths universally
24+
25+
A deeper alternative would be to change the internal data model so that all collected paths are stored as full repo-root-relative paths from the moment they are collected, removing the dual representation entirely. `isSupportedPackageInstallablePath` would be updated to validate against full paths. This was rejected as out-of-scope for a targeted bug fix: it would require coordinated changes across multiple callers and data structures, and carries a higher risk of introducing regressions in root-bundle behaviour, which is unaffected by the current bug.
26+
27+
### Consequences
28+
29+
#### Positive
30+
- Nested bundles that reference `.github/workflows/` paths now install correctly without HTTP 404 errors.
31+
- The convention mirrors the behaviour of GitHub itself — `.github/` is always a repo-root directory — reducing cognitive overhead for manifest authors.
32+
- Auto-scan for nested bundles now correctly strips the package prefix before validating path support, so previously silently-dropped scan results surface as expected.
33+
34+
#### Negative
35+
- The path resolution semantics are now context-sensitive: the same path string is interpreted differently depending on its prefix (`.github/` vs everything else). This implicit rule is documented only in code comments, which may not be visible to future manifest authors or contributors unfamiliar with the area.
36+
- There is no compile-time or schema-level enforcement of the convention; a future refactor of path normalization could inadvertently break the special case if the comments are not noticed.
37+
38+
#### Neutral
39+
- The fix requires no changes to the `aw.yml` schema or any public-facing API surface; existing valid manifests are unaffected.
40+
- New tests (`TestResolveWorkflows_NestedRepositoryPackage_GithubWorkflowsPathIsRepoRoot`, `TestResolveWorkflows_NestedRepositoryPackage_AutoScan`) encode the expected behaviour and will catch regressions in both paths.
41+
42+
---
43+
44+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

pkg/cli/add_package_manifest.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,15 @@ func scanRepositoryPackageInstallablePaths(owner, repo, packagePath, ref, host s
677677
}
678678

679679
for _, file := range files {
680-
if !isSupportedPackageInstallablePath(file) {
680+
// listPackageWorkflowFilesForHost returns full repo-root-relative paths
681+
// (e.g. "folder/workflows/foo.md" when scanning "folder/workflows/").
682+
// isSupportedPackageInstallablePath expects package-relative paths, so
683+
// strip the package prefix before validation for nested bundles.
684+
pathToValidate := file
685+
if packagePath != "" {
686+
pathToValidate = strings.TrimPrefix(file, packagePath+"/")
687+
}
688+
if !isSupportedPackageInstallablePath(pathToValidate) {
681689
continue
682690
}
683691
if _, exists := seen[file]; exists {
@@ -718,7 +726,16 @@ func normalizePackageInstallablePaths(paths []string, packagePath string) []stri
718726
if !isSupportedPackageInstallablePath(path) {
719727
continue
720728
}
721-
path = joinRepositoryPackagePath(packagePath, path)
729+
// Paths under .github/ are treated as repo-root-relative even in nested
730+
// bundles (e.g. a bundle at "dependabot/" with ".github/workflows/foo.md"
731+
// refers to the repository-root ".github/workflows/foo.md", not to
732+
// "dependabot/.github/workflows/foo.md"). All other paths (e.g. workflows/,
733+
// agentic-workflows/) remain relative to the package root.
734+
if packagePath != "" && strings.HasPrefix(path, constants.GithubDir) {
735+
path = filepath.ToSlash(path)
736+
} else {
737+
path = joinRepositoryPackagePath(packagePath, path)
738+
}
722739
if _, exists := seen[path]; exists {
723740
continue
724741
}

pkg/cli/add_package_manifest_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,38 @@ files:
559559
assert.Equal(t, "packages/repo-assist/README.md", pkg.DocsPath)
560560
assert.Equal(t, []string{"packages/repo-assist/workflows/review.md"}, pkg.InstallationSource)
561561
})
562+
563+
t.Run("nested bundle github workflows paths are repo-root-relative", func(t *testing.T) {
564+
// When a nested bundle manifest (at "dependabot/aw.yml") lists a
565+
// ".github/workflows/" path, it must resolve to the repo-root path
566+
// ".github/workflows/foo.md" — NOT to "dependabot/.github/workflows/foo.md".
567+
downloadPackageFileFromGitHubForHost = func(owner, repo, path, ref, host string) ([]byte, error) {
568+
switch path {
569+
case "dependabot/aw.yml":
570+
return []byte(`name: Dependabot
571+
files:
572+
- workflows/review.md
573+
- .github/workflows/dependabot-orchestrator.md
574+
`), nil
575+
case "dependabot/README.md":
576+
return []byte("# Dependabot\n"), nil
577+
default:
578+
return nil, createRepositoryPackageNotFoundError(path)
579+
}
580+
}
581+
listPackageWorkflowFilesForHost = func(owner, repo, ref, workflowPath, host string) ([]string, error) {
582+
t.Fatalf("unexpected scan of %s", workflowPath)
583+
return nil, nil
584+
}
585+
586+
pkg, err := resolveRepositoryPackage(&RepoSpec{RepoSlug: "owner/repo", PackagePath: "dependabot"}, "")
587+
require.NoError(t, err)
588+
// workflows/ path is package-relative; .github/workflows/ is repo-root-relative.
589+
assert.Equal(t, []string{
590+
"dependabot/workflows/review.md",
591+
".github/workflows/dependabot-orchestrator.md",
592+
}, pkg.InstallationSource)
593+
})
562594
}
563595

564596
func TestResolveWorkflows_RepositoryPackage(t *testing.T) {
@@ -729,6 +761,128 @@ files:
729761
assert.Equal(t, "folder/workflows/review.md", resolved.Workflows[0].Spec.WorkflowPath)
730762
}
731763

764+
// TestResolveWorkflows_NestedRepositoryPackage_GithubWorkflowsPathIsRepoRoot reproduces
765+
// the bug reported in gh-aw#41789: a ".github/workflows/" path listed in the manifest of
766+
// a nested bundle (e.g. "dependabot/aw.yml") must resolve to the repository-root path
767+
// ".github/workflows/foo.md", not to "dependabot/.github/workflows/foo.md".
768+
func TestResolveWorkflows_NestedRepositoryPackage_GithubWorkflowsPathIsRepoRoot(t *testing.T) {
769+
originalFetchFn := fetchWorkflowFromSourceWithContextFn
770+
originalDownload := downloadPackageFileFromGitHubForHost
771+
originalList := listPackageWorkflowFilesForHost
772+
originalDirFiles := listPackageDirFilesForHost
773+
originalDirSubdirs := listPackageDirSubdirsForHost
774+
originalDefaultBranch := getRepositoryPackageDefaultBranch
775+
t.Cleanup(func() {
776+
fetchWorkflowFromSourceWithContextFn = originalFetchFn
777+
downloadPackageFileFromGitHubForHost = originalDownload
778+
listPackageWorkflowFilesForHost = originalList
779+
listPackageDirFilesForHost = originalDirFiles
780+
listPackageDirSubdirsForHost = originalDirSubdirs
781+
getRepositoryPackageDefaultBranch = originalDefaultBranch
782+
})
783+
getRepositoryPackageDefaultBranch = func(repoSlug, host string) (string, error) {
784+
return "main", nil
785+
}
786+
listPackageDirFilesForHost = func(owner, repo, ref, dirPath, host string) ([]string, error) {
787+
return nil, createRepositoryPackageNotFoundError(dirPath)
788+
}
789+
listPackageDirSubdirsForHost = func(owner, repo, ref, dirPath, host string) ([]string, error) {
790+
return nil, createRepositoryPackageNotFoundError(dirPath)
791+
}
792+
793+
downloadPackageFileFromGitHubForHost = func(owner, repo, path, ref, host string) ([]byte, error) {
794+
switch path {
795+
case "dependabot/aw.yml":
796+
return []byte(`name: Dependabot Workflows
797+
files:
798+
- .github/workflows/dependabot-orchestrator.md
799+
`), nil
800+
case "dependabot/README.md":
801+
return []byte("# Dependabot Workflows\n"), nil
802+
}
803+
return nil, createRepositoryPackageNotFoundError(path)
804+
}
805+
listPackageWorkflowFilesForHost = func(owner, repo, ref, workflowPath, host string) ([]string, error) {
806+
t.Fatalf("unexpected scan of %s", workflowPath)
807+
return nil, nil
808+
}
809+
fetchWorkflowFromSourceWithContextFn = func(_ context.Context, spec *WorkflowSpec, _ bool) (*FetchedWorkflow, error) {
810+
return &FetchedWorkflow{
811+
Content: []byte("---\nname: Dependabot Orchestrator\non: push\n---\n"),
812+
CommitSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
813+
IsLocal: false,
814+
SourcePath: spec.WorkflowPath,
815+
}, nil
816+
}
817+
818+
resolved, err := ResolveWorkflows(context.Background(), []string{"owner/repo/dependabot"}, false)
819+
require.NoError(t, err)
820+
require.Len(t, resolved.Workflows, 1)
821+
// Must be the repo-root path, NOT "dependabot/.github/workflows/dependabot-orchestrator.md".
822+
assert.Equal(t, ".github/workflows/dependabot-orchestrator.md", resolved.Workflows[0].Spec.WorkflowPath)
823+
}
824+
825+
// TestResolveWorkflows_NestedRepositoryPackage_AutoScan tests that auto-scan (no explicit
826+
// files: in the manifest) finds workflows inside a nested bundle's own directories.
827+
func TestResolveWorkflows_NestedRepositoryPackage_AutoScan(t *testing.T) {
828+
originalFetchFn := fetchWorkflowFromSourceWithContextFn
829+
originalDownload := downloadPackageFileFromGitHubForHost
830+
originalList := listPackageWorkflowFilesForHost
831+
originalDirFiles := listPackageDirFilesForHost
832+
originalDirSubdirs := listPackageDirSubdirsForHost
833+
originalDefaultBranch := getRepositoryPackageDefaultBranch
834+
t.Cleanup(func() {
835+
fetchWorkflowFromSourceWithContextFn = originalFetchFn
836+
downloadPackageFileFromGitHubForHost = originalDownload
837+
listPackageWorkflowFilesForHost = originalList
838+
listPackageDirFilesForHost = originalDirFiles
839+
listPackageDirSubdirsForHost = originalDirSubdirs
840+
getRepositoryPackageDefaultBranch = originalDefaultBranch
841+
})
842+
getRepositoryPackageDefaultBranch = func(repoSlug, host string) (string, error) {
843+
return "main", nil
844+
}
845+
listPackageDirFilesForHost = func(owner, repo, ref, dirPath, host string) ([]string, error) {
846+
return nil, createRepositoryPackageNotFoundError(dirPath)
847+
}
848+
listPackageDirSubdirsForHost = func(owner, repo, ref, dirPath, host string) ([]string, error) {
849+
return nil, createRepositoryPackageNotFoundError(dirPath)
850+
}
851+
852+
downloadPackageFileFromGitHubForHost = func(owner, repo, path, ref, host string) ([]byte, error) {
853+
switch path {
854+
case "mypkg/aw.yml":
855+
return []byte("name: My Package\n"), nil
856+
case "mypkg/README.md":
857+
return []byte("# My Package\n"), nil
858+
}
859+
return nil, createRepositoryPackageNotFoundError(path)
860+
}
861+
// Auto-scan returns full repo-root-relative paths, as the GitHub API does.
862+
listPackageWorkflowFilesForHost = func(owner, repo, ref, workflowPath, host string) ([]string, error) {
863+
switch workflowPath {
864+
case "mypkg/workflows":
865+
return []string{"mypkg/workflows/pr-review.md"}, nil
866+
default:
867+
return nil, createRepositoryPackageNotFoundError(workflowPath)
868+
}
869+
}
870+
fetchWorkflowFromSourceWithContextFn = func(_ context.Context, spec *WorkflowSpec, _ bool) (*FetchedWorkflow, error) {
871+
return &FetchedWorkflow{
872+
Content: []byte("---\nname: PR Review\non: push\n---\n"),
873+
CommitSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
874+
IsLocal: false,
875+
SourcePath: spec.WorkflowPath,
876+
}, nil
877+
}
878+
879+
resolved, err := ResolveWorkflows(context.Background(), []string{"owner/repo/mypkg"}, false)
880+
require.NoError(t, err)
881+
require.Len(t, resolved.Workflows, 1)
882+
// Auto-scanned path must be the full repo-root path returned by the API.
883+
assert.Equal(t, "mypkg/workflows/pr-review.md", resolved.Workflows[0].Spec.WorkflowPath)
884+
}
885+
732886
func TestResolveWorkflows_FallsBackToWorkflowWhenNestedManifestMissing(t *testing.T) {
733887
originalFetchFn := fetchWorkflowFromSourceWithContextFn
734888
originalDownload := downloadPackageFileFromGitHubForHost

0 commit comments

Comments
 (0)