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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/adr/36227-reject-private-field-in-manifest-workflows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# ADR-36227: Enforce `private: true` for Add/Package Blocking

**Date**: 2026-06-01
**Status**: Draft

## Context

`aw.yml` package manifests can list installable workflows that other repositories add via `gh aw add` / `gh aw add-wizard`. The `private` frontmatter field is intended to block installation only when it is explicitly set to `true`. Manifest-backed resolution and compile-time validation must preserve that semantics consistently, so package workflows only fail when they declare `private: true`.

## Decision

We keep `ExtractWorkflowPrivateSetting(content) (value, present bool)` for frontmatter parsing, but manifest-backed resolution (`ResolveWorkflows` for `FromRepositoryManifest` specs) and compile-time local manifest validation reject only `private: true`. `private: false` remains installable.

## Alternatives Considered

### Alternative 1: Reject any manifest-listed `private` declaration
Reject `private: true` and `private: false` equally for manifest-listed workflows. This was rejected because `private: false` is not a disable signal and should not block install/package behavior.

### Alternative 2: Strip or normalize the `private` field during manifest install instead of respecting value
Silently normalize `private` during manifest resolution. This was rejected because it hides intent; explicit `private: true` should continue to fail loudly.

## Consequences

### Positive
- Add/package and compile-time validation consistently reject only workflows that declare `private: true`.
- Error messages continue to name the offending workflow path when rejection occurs.

### Negative
- Existing manifests using `private: false` remain installable; only `private: true` workflows are blocked.
- Introduces a second extraction function (`ExtractWorkflowPrivateSetting` alongside `ExtractWorkflowPrivate`) and parallel checks in both the resolution and compile-time paths that must stay in sync.

### Neutral
- Standalone (non-manifest) workflow adds are unchanged: `private: true` still blocks installation via the preserved `ExtractWorkflowPrivate` path.
- Compile-time validation scans manifest-listed installable paths (explicit `files:`/`includes:` entries, or discovered package directories) using the same `private: true` semantics.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/26752819392) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
20 changes: 20 additions & 0 deletions pkg/cli/add_package_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,26 @@ func normalizePackageInstallablePaths(paths []string, packagePath string) []stri
return normalized
}

func validateManifestInstallableWorkflowPrivacy(manifestPath string, installationSources []string, readWorkflow func(string) ([]byte, error)) error {
for _, installationSource := range installationSources {
if isActionWorkflowPath(installationSource) {
continue
}

content, err := readWorkflow(installationSource)
if err != nil {
return fmt.Errorf("invalid Agentic Workflow manifest %q: %w", manifestPath, err)
}

privateValue, hasPrivate := ExtractWorkflowPrivateSetting(string(content))
if hasPrivate && privateValue {
return fmt.Errorf("invalid Agentic Workflow manifest %q: workflow %q sets private: true and cannot be included because private workflows cannot be added", manifestPath, installationSource)
}
}

return nil
}

func isSupportedPackageInstallablePath(p string) bool {
// Normalize separators to forward slashes (consistent with joinRepositoryPackagePath) then
// clean to reject path traversal (e.g. "workflows/../README.md" → "README.md").
Expand Down
54 changes: 54 additions & 0 deletions pkg/cli/add_package_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,60 @@ files:
assert.Equal(t, ".github/workflows/nightly-review.md", resolved.Workflows[1].Spec.WorkflowPath)
}

func TestResolveWorkflows_RepositoryPackageRejectsPrivateTrue(t *testing.T) {
originalFetchFn := fetchWorkflowFromSourceWithContextFn
originalDownload := downloadPackageFileFromGitHubForHost
originalList := listPackageWorkflowFilesForHost
originalDirFiles := listPackageDirFilesForHost
originalDirSubdirs := listPackageDirSubdirsForHost
originalDefaultBranch := getRepositoryPackageDefaultBranch
t.Cleanup(func() {
fetchWorkflowFromSourceWithContextFn = originalFetchFn
downloadPackageFileFromGitHubForHost = originalDownload
listPackageWorkflowFilesForHost = originalList
listPackageDirFilesForHost = originalDirFiles
listPackageDirSubdirsForHost = originalDirSubdirs
getRepositoryPackageDefaultBranch = originalDefaultBranch
})
getRepositoryPackageDefaultBranch = func(repoSlug, host string) (string, error) {
return "main", nil
}
listPackageDirFilesForHost = func(owner, repo, ref, dirPath, host string) ([]string, error) {
return nil, createRepositoryPackageNotFoundError(dirPath)
}
listPackageDirSubdirsForHost = func(owner, repo, ref, dirPath, host string) ([]string, error) {
return nil, createRepositoryPackageNotFoundError(dirPath)
}
downloadPackageFileFromGitHubForHost = func(owner, repo, path, ref, host string) ([]byte, error) {
switch path {
case "aw.yml":
return []byte("name: Repo Assist\nfiles:\n - workflows/review.md\n"), nil
case "README.md":
return []byte("# Repo Assist\n"), nil
case "workflows/review.md":
return []byte("---\nprivate: true\n---\n\n# Review\n"), nil
default:
return nil, createRepositoryPackageNotFoundError(path)
}
}
listPackageWorkflowFilesForHost = func(owner, repo, ref, workflowPath, host string) ([]string, error) {
t.Fatalf("unexpected scan of %s", workflowPath)
return nil, nil
}
fetchWorkflowFromSourceWithContextFn = func(_ context.Context, spec *WorkflowSpec, _ bool) (*FetchedWorkflow, error) {
return &FetchedWorkflow{
Content: []byte("---\nprivate: true\n---\n\n# Review\n"),
CommitSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
IsLocal: false,
SourcePath: spec.WorkflowPath,
}, nil
}

_, err := ResolveWorkflows(context.Background(), []string{"owner/repo"}, false)
require.Error(t, err)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The new test covers private: false through a manifest-backed install, but there's no corresponding test for private: true arriving via FromRepositoryManifest. The check in add_workflow_resolution.go has two branches (privateValue true vs false) — only the false branch is exercised here.

💡 Suggested addition

Add a TestResolveWorkflows_RepositoryPackageRejectsPrivateTrue mirror of this test where workflows/review.md contains private: true. This confirms both branches of the inline check in ResolveWorkflows and guards against the error message being accidentally removed from the true branch.

assert.Contains(t, err.Error(), `workflow "workflows/review.md" sets private: true`)
}

func TestResolveWorkflows_NestedRepositoryPackage(t *testing.T) {
originalFetchFn := fetchWorkflowFromSourceWithContextFn
originalDownload := downloadPackageFileFromGitHubForHost
Expand Down
8 changes: 8 additions & 0 deletions pkg/cli/add_workflow_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ func ResolveWorkflows(ctx context.Context, workflows []string, verbose bool) (*R
// Extract engine from content (if specified in frontmatter)
engine := ExtractWorkflowEngine(string(fetched.Content))

if spec.FromRepositoryManifest {
privateValue, hasPrivate := ExtractWorkflowPrivateSetting(string(fetched.Content))
if hasPrivate && privateValue {
manifestPath := joinRepositoryPackagePath(spec.PackagePath, repositoryPackageManifestFileName)
return nil, fmt.Errorf("invalid Agentic Workflow manifest %q: workflow %q sets private: true and cannot be included because private workflows cannot be added", manifestPath, resolvedSpec.WorkflowPath)
}
}

// Check if workflow is private - private workflows cannot be added to other repositories
isPrivate := ExtractWorkflowPrivate(string(fetched.Content))
if isPrivate {
Expand Down
68 changes: 67 additions & 1 deletion pkg/cli/compile_repository_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"

Expand Down Expand Up @@ -102,10 +103,75 @@ func findLocalRepositoryPackageManifest(gitRoot string) (string, error) {
func validateLocalRepositoryPackageContents(manifestPath string) error {
readmePath := filepath.Join(filepath.Dir(manifestPath), "README.md")
if _, err := os.Stat(readmePath); err == nil {
return nil
manifestContent, err := os.ReadFile(manifestPath)
if err != nil {
return fmt.Errorf("failed to read Agentic Workflow manifest %q: %w", manifestPath, err)
}
manifest, _, err := parseRepositoryPackageManifest(manifestPath, manifestContent)
if err != nil {
return err
}

includeInstallablePaths, _, _ := splitManifestIncludePaths(manifest.Includes)
includeInstallablePaths = append(includeInstallablePaths, manifest.Files...)
installationSources := normalizePackageInstallablePaths(includeInstallablePaths, "")
if len(installationSources) == 0 {
installationSources, err = scanLocalRepositoryPackageInstallablePaths(filepath.Dir(manifestPath))
if err != nil {
return err
}
}

return validateManifestInstallableWorkflowPrivacy(manifestPath, installationSources, func(sourcePath string) ([]byte, error) {
content, err := os.ReadFile(filepath.Join(filepath.Dir(manifestPath), filepath.FromSlash(sourcePath)))
if err != nil {
return nil, fmt.Errorf("failed to read workflow %q: %w", sourcePath, err)
}
return content, nil
})
} else if os.IsNotExist(err) {
return fmt.Errorf("invalid Agentic Workflow manifest %q: missing required README.md", manifestPath)
} else {
return fmt.Errorf("failed to read package README %q: %w", readmePath, err)
}
}

func scanLocalRepositoryPackageInstallablePaths(packageDir string) ([]string, error) {
var collected []string
seen := make(map[string]struct{})

for _, sourceDir := range packageSourceDirectories {
sourcePath := filepath.Join(packageDir, filepath.FromSlash(sourceDir))
err := filepath.WalkDir(sourcePath, func(currentPath string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}

relativePath, err := filepath.Rel(packageDir, currentPath)
if err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] filepath.WalkDir returns walkErr to the callback for entries it could not stat (e.g. a broken symlink or a permission-denied file inside the directory). Returning walkErr immediately aborts the entire scan — a single unreadable file fails the whole compilation.

💡 Suggestion

Consider skipping non-fatal walk errors on individual files rather than aborting:

if walkErr != nil {
    if d != nil && !d.IsDir() {
        return nil // skip unreadable file, keep scanning
    }
    return walkErr // propagate errors opening directories
}

This keeps the scan robust in the presence of broken symlinks or permission quirks in the package tree.

return err
}
relativePath = filepath.ToSlash(relativePath)
if !isSupportedPackageInstallablePath(relativePath) {
return nil
}
if _, exists := seen[relativePath]; exists {
return nil
}
seen[relativePath] = struct{}{}
collected = append(collected, relativePath)
return nil
})
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf("failed to scan %q: %w", sourcePath, err)
}
}

return collected, nil
}
30 changes: 30 additions & 0 deletions pkg/cli/compile_repository_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,36 @@ name: Repo Assist
assert.Contains(t, err.Error(), "missing required README.md")
}

func TestCompileWorkflows_RejectsManifestWorkflowWithPrivateTrue(t *testing.T) {
tmpDir := testutil.TempDir(t, "aw-manifest-private-true-*")
originalWd, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(originalWd) })
require.NoError(t, os.Chdir(tmpDir))

cmd := exec.Command("git", "init")
cmd.Dir = tmpDir
require.NoError(t, cmd.Run())

require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "workflows"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "workflows", "review.md"), []byte(`---
private: true
---

# Review
`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "README.md"), []byte("# Repo Assist\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "aw.yml"), []byte(`manifest-version: "1"
name: Repo Assist
files:
- workflows/review.md
`), 0o644))

_, err = CompileWorkflows(context.Background(), CompileConfig{})
require.Error(t, err)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The new scanLocalRepositoryPackageInstallablePaths fallback (triggered when manifest.Files and manifest.Includes are both empty) has no test coverage. This is the only code path that actually walks the filesystem, applies deduplication, and skips missing source directories — all independently from the files:-listing path tested here.

💡 Suggested test sketch

Add a test variant that omits files: from aw.yml so the compile-time path falls through to the scanner:

func TestCompileWorkflows_RejectsAutoDiscoveredWorkflowWithPrivate(t *testing.T) {
    // Same setup, but aw.yml has no `files:` key
    os.WriteFile(..., []byte(`manifest-version: "1"\nname: Repo Assist\n`), 0o644)
    // review.md with private: true or private: false lives under workflows/
    _, err = CompileWorkflows(context.Background(), CompileConfig{})
    require.Error(t, err)
    assert.Contains(t, err.Error(), `sets private:`)
}

Without this, a regression in WalkDir path normalization or deduplication would be invisible.

assert.Contains(t, err.Error(), `workflow "workflows/review.md" sets private: true`)
}

func TestValidateRepositoryManifestForCompilation_PropagatesGitRootErrors(t *testing.T) {
originalFindGitRoot := findGitRootForManifestValidation
t.Cleanup(func() {
Expand Down
20 changes: 15 additions & 5 deletions pkg/cli/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,20 +230,30 @@ func ExtractWorkflowEngine(content string) string {
return ""
}

// ExtractWorkflowPrivate extracts the private field from workflow content string.
// Returns true if the workflow has private: true in its frontmatter.
func ExtractWorkflowPrivate(content string) bool {
// ExtractWorkflowPrivateSetting extracts the private field from workflow content string.
// Returns the boolean value and whether the field was explicitly present.
func ExtractWorkflowPrivateSetting(content string) (bool, bool) {
result, err := parser.ExtractFrontmatterFromContent(content)
if err != nil {
return false
return false, false
}

if private, ok := result.Frontmatter["private"]; ok {
if privateBool, ok := private.(bool); ok {
return privateBool
return privateBool, true
}
}

return false, false
}
Comment on lines 241 to +248

// ExtractWorkflowPrivate extracts the private field from workflow content string.
// Returns true if the workflow has private: true in its frontmatter.
func ExtractWorkflowPrivate(content string) bool {
privateBool, ok := ExtractWorkflowPrivateSetting(content)
if ok {
return privateBool
}
return false
}

Expand Down