-
Notifications
You must be signed in to change notification settings - Fork 413
Enforce manifest-level rejection of workflows that declare private: true
#36227
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4283c4c
c25f30d
c7f6c40
31304fd
c1ce795
351ea82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ package cli | |
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "io/fs" | ||
| "os" | ||
| "path/filepath" | ||
|
|
||
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] 💡 SuggestionConsider 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The new 💡 Suggested test sketchAdd a test variant that omits 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 |
||
| assert.Contains(t, err.Error(), `workflow "workflows/review.md" sets private: true`) | ||
| } | ||
|
|
||
| func TestValidateRepositoryManifestForCompilation_PropagatesGitRootErrors(t *testing.T) { | ||
| originalFindGitRoot := findGitRootForManifestValidation | ||
| t.Cleanup(func() { | ||
|
|
||
There was a problem hiding this comment.
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: falsethrough a manifest-backed install, but there's no corresponding test forprivate: truearriving viaFromRepositoryManifest. The check inadd_workflow_resolution.gohas two branches (privateValuetrue vs false) — only thefalsebranch is exercised here.💡 Suggested addition
Add a
TestResolveWorkflows_RepositoryPackageRejectsPrivateTruemirror of this test whereworkflows/review.mdcontainsprivate: true. This confirms both branches of the inline check inResolveWorkflowsand guards against the error message being accidentally removed from thetruebranch.