diff --git a/.changeset/patch-aw-yml-schedule-seed.md b/.changeset/patch-aw-yml-schedule-seed.md new file mode 100644 index 00000000000..d43c46f5059 --- /dev/null +++ b/.changeset/patch-aw-yml-schedule-seed.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Allow `aw.yml` to provide the default seed for fuzzy schedule scattering. 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 f401b4349d0..658278c418d 100644 --- a/docs/src/content/docs/reference/aw-yml-package-manifest.md +++ b/docs/src/content/docs/reference/aw-yml-package-manifest.md @@ -33,6 +33,7 @@ The package root is the folder that contains `aw.yml`. | `description` | string | No | Optional package description. `gh aw add` warns when it exceeds 255 characters. | | `private` | boolean | No | Marks the package as unavailable for installation. Defaults to `false`; `gh aw add` refuses packages set to `true`. | | `experimental` | boolean | No | Marks the package as experimental. Defaults to `false`; `gh aw add` displays a warning when set to `true`. | +| `schedule-seed` | string | No | Repository slug in `owner/repo` form used for fuzzy schedule scattering by `gh aw compile`. The `--schedule-seed` flag takes precedence. | | `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, or paths to other `aw.yml` manifests whose installable files are included recursively. Each entry is either a path string (same rules as `files`, plus skill and agent paths), a path ending in `/*` that matches supported direct children, or a source-to-destination mapping. | | `resources` | array | No | Repository assets copied from package-relative `source` paths to allowlisted repository-relative `destination` paths. | 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 0c4e0a23757..a599dffc2c9 100644 --- a/docs/src/content/docs/specs/repository-package-manifest-specification.md +++ b/docs/src/content/docs/specs/repository-package-manifest-specification.md @@ -50,6 +50,7 @@ The manifest document MUST be a YAML mapping. Unknown top-level fields MUST be r | `license` | string | No | SPDX license identifier or license name for the package. | | `private` | boolean | No | Whether the package is unavailable for installation. Defaults to `false`. | | `experimental` | boolean | No | Whether the package is experimental. Defaults to `false`. | +| `schedule-seed` | string | No | Repository slug used for fuzzy schedule scattering when the compile flag is omitted. | | `imports` | array of strings | No | Paths to package manifests included recursively in the install set. | | `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. | @@ -104,6 +105,12 @@ If omitted, `private` defaults to `false`. When `private` is `true`, `gh aw add` If omitted, `experimental` defaults to `false`. When `experimental` is `true`, `gh aw add` MUST warn before installing the package. +### 4.9.1 `schedule-seed` + +If present, `schedule-seed` MUST be a string in `owner/repo` form. `gh aw compile` +MUST use it for fuzzy schedule scattering when `--schedule-seed` is omitted. An +explicit `--schedule-seed` value MUST take precedence. + ### 4.10 `files` If present, `files` MUST be an array of strings. diff --git a/pkg/cli/add_package_manifest_parse.go b/pkg/cli/add_package_manifest_parse.go index 0c665ba076d..f0a8128c4aa 100644 --- a/pkg/cli/add_package_manifest_parse.go +++ b/pkg/cli/add_package_manifest_parse.go @@ -29,6 +29,7 @@ type repositoryPackageManifest struct { License string Private bool Experimental bool + ScheduleSeed string Imports []string Includes []repositoryPackageInclude Files []string @@ -173,6 +174,9 @@ func populateRepositoryPackageManifestBasicMetadata(manifest *repositoryPackageM if experimental, ok := root["experimental"].(bool); ok { manifest.Experimental = experimental } + if scheduleSeed, ok := stringValue(root["schedule-seed"]); ok { + manifest.ScheduleSeed = scheduleSeed + } } func populateRepositoryPackageManifestExtensions(manifest *repositoryPackageManifest, root map[string]any, manifestPath string, warnings []string) ([]string, error) { diff --git a/pkg/cli/compile_orchestrator.go b/pkg/cli/compile_orchestrator.go index 583233e72a7..46c4fb0a744 100644 --- a/pkg/cli/compile_orchestrator.go +++ b/pkg/cli/compile_orchestrator.go @@ -115,10 +115,8 @@ func CompileWorkflows(ctx context.Context, config CompileConfig) ([]*workflow.Wo return nil, err } - compiler := createAndConfigureCompiler(config) - compiler.SetContext(ctx) - - if err := validateRepositoryManifestForCompilation(config, stats, &validationResults); err != nil { + manifest, err := validateRepositoryManifestForCompilation(config, stats, &validationResults) + if err != nil { if config.JSONOutput { if outputErr := outputResults(stats, &validationResults, config); outputErr != nil { return nil, outputErr @@ -126,6 +124,10 @@ func CompileWorkflows(ctx context.Context, config CompileConfig) ([]*workflow.Wo } return nil, err } + config = applyRepositoryManifestDefaults(config, manifest) + + compiler := createAndConfigureCompiler(config) + compiler.SetContext(ctx) // Handle watch mode (early return) if config.Watch { diff --git a/pkg/cli/compile_repository_manifest.go b/pkg/cli/compile_repository_manifest.go index 2a090f362b7..8040ff62f45 100644 --- a/pkg/cli/compile_repository_manifest.go +++ b/pkg/cli/compile_repository_manifest.go @@ -16,34 +16,41 @@ var compileRepositoryManifestLog = logger.New("cli:compile_repository_manifest") var findGitRootForManifestValidation = gitutil.FindGitRoot -func validateRepositoryManifestForCompilation(config CompileConfig, stats *CompilationStats, validationResults *[]ValidationResult) error { +func applyRepositoryManifestDefaults(config CompileConfig, manifest *repositoryPackageManifest) CompileConfig { + if config.ScheduleSeed == "" && manifest != nil { + config.ScheduleSeed = manifest.ScheduleSeed + } + return config +} + +func validateRepositoryManifestForCompilation(config CompileConfig, stats *CompilationStats, validationResults *[]ValidationResult) (*repositoryPackageManifest, error) { compileRepositoryManifestLog.Print("Validating repository manifest for compilation") gitRoot, err := findGitRootForManifestValidation() if err != nil { if errors.Is(err, gitutil.ErrNotGitRepository) { compileRepositoryManifestLog.Print("Not in a git repository, skipping manifest validation") - return nil + return nil, nil } - return fmt.Errorf("failed to find git root for manifest validation: %w", err) + return nil, fmt.Errorf("failed to find git root for manifest validation: %w", err) } manifestPath, err := findLocalRepositoryPackageManifest(gitRoot) if err != nil { - return err + return nil, err } if manifestPath == "" { compileRepositoryManifestLog.Printf("No repository manifest found in %s", gitRoot) - return nil + return nil, nil } compileRepositoryManifestLog.Printf("Found repository manifest at %s", manifestPath) content, err := os.ReadFile(manifestPath) if err != nil { - return fmt.Errorf("failed to read Agentic Workflow manifest %q: %w", manifestPath, err) + return nil, fmt.Errorf("failed to read Agentic Workflow manifest %q: %w", manifestPath, err) } - _, warnings, parseErr := parseRepositoryPackageManifest(manifestPath, content) + manifest, warnings, parseErr := parseRepositoryPackageManifest(manifestPath, content) if parseErr == nil { parseErr = validateLocalRepositoryPackageContents(manifestPath) } @@ -62,7 +69,10 @@ func validateRepositoryManifestForCompilation(config CompileConfig, stats *Compi Message: warning, }) } - return reportRepositoryManifestValidation(config, validationResults, warnings, parseErr, result) + if err := reportRepositoryManifestValidation(config, validationResults, warnings, parseErr, result); err != nil { + return nil, err + } + return manifest, nil } func reportRepositoryManifestValidation(config CompileConfig, validationResults *[]ValidationResult, warnings []string, parseErr error, result ValidationResult) error { diff --git a/pkg/cli/compile_repository_manifest_test.go b/pkg/cli/compile_repository_manifest_test.go index 051b54256ac..28c3f9661aa 100644 --- a/pkg/cli/compile_repository_manifest_test.go +++ b/pkg/cli/compile_repository_manifest_test.go @@ -12,7 +12,9 @@ import ( "path/filepath" "testing" + "github.com/github/gh-aw/pkg/parser" "github.com/github/gh-aw/pkg/testutil" + "github.com/github/gh-aw/pkg/workflow" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -179,6 +181,46 @@ engine: copilot require.NoError(t, err) } +func TestCompileWorkflows_UsesManifestScheduleSeed(t *testing.T) { + tmpDir := testutil.TempDir(t, "aw-manifest-schedule-seed-*") + 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, ".github", "workflows"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github", "workflows", "test.md"), []byte(`--- +on: daily +permissions: + contents: read +engine: copilot +--- + +# Test +`), 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(`name: Repo Assist +schedule-seed: github/gh-aw +`), 0o644)) + + wasRelease := workflow.IsRelease() + workflow.SetIsRelease(true) + t.Cleanup(func() { workflow.SetIsRelease(wasRelease) }) + + _, err = CompileWorkflows(context.Background(), CompileConfig{}) + require.NoError(t, err) + + lockContent, err := os.ReadFile(filepath.Join(tmpDir, ".github", "workflows", "test.lock.yml")) + require.NoError(t, err) + expectedCron, err := parser.ScatterSchedule("FUZZY:DAILY * * *", "github/gh-aw/.github/workflows/test.md") + require.NoError(t, err) + assert.Contains(t, string(lockContent), expectedCron, "compiled schedule should use the aw.yml schedule seed") +} + func TestCompileWorkflows_ResolvesImportedManifestRootRelativeWorkflow(t *testing.T) { tmpDir := testutil.TempDir(t, "aw-manifest-import-root-relative-*") originalWd, err := os.Getwd() @@ -287,8 +329,45 @@ func TestValidateRepositoryManifestForCompilation_PropagatesGitRootErrors(t *tes stats := &CompilationStats{} var results []ValidationResult - err := validateRepositoryManifestForCompilation(CompileConfig{}, stats, &results) + _, err := validateRepositoryManifestForCompilation(CompileConfig{}, stats, &results) require.Error(t, err) require.ErrorContains(t, err, "failed to find git root for manifest validation") require.ErrorContains(t, err, "permission denied") } + +func TestApplyRepositoryManifestDefaults_ScheduleSeed(t *testing.T) { + t.Parallel() + + manifest := &repositoryPackageManifest{ScheduleSeed: "github/gh-aw"} + + t.Run("uses manifest value when flag omitted", func(t *testing.T) { + config := applyRepositoryManifestDefaults(CompileConfig{}, manifest) + assert.Equal(t, "github/gh-aw", config.ScheduleSeed) + }) + + t.Run("preserves explicit flag value", func(t *testing.T) { + config := applyRepositoryManifestDefaults(CompileConfig{ScheduleSeed: "octo/repo"}, manifest) + assert.Equal(t, "octo/repo", config.ScheduleSeed) + }) + + t.Run("handles missing manifest", func(t *testing.T) { + config := applyRepositoryManifestDefaults(CompileConfig{}, nil) + assert.Empty(t, config.ScheduleSeed) + }) +} + +func TestParseRepositoryPackageManifest_ScheduleSeed(t *testing.T) { + t.Parallel() + + manifest, _, err := parseRepositoryPackageManifest("aw.yml", []byte("name: Repo Assist\nschedule-seed: github/gh-aw\n")) + require.NoError(t, err) + assert.Equal(t, "github/gh-aw", manifest.ScheduleSeed) +} + +func TestParseRepositoryPackageManifest_RejectsInvalidScheduleSeed(t *testing.T) { + t.Parallel() + + _, _, err := parseRepositoryPackageManifest("aw.yml", []byte("name: Repo Assist\nschedule-seed: invalid\n")) + require.Error(t, err) + require.ErrorContains(t, err, "schedule-seed") +} diff --git a/pkg/parser/schemas/aw_manifest_schema.json b/pkg/parser/schemas/aw_manifest_schema.json index 5f7f3eb62b6..46e67d02415 100644 --- a/pkg/parser/schemas/aw_manifest_schema.json +++ b/pkg/parser/schemas/aw_manifest_schema.json @@ -40,6 +40,11 @@ "default": false, "description": "Whether the package is experimental and may change without notice." }, + "schedule-seed": { + "type": "string", + "pattern": "^[^/\\s]+/[^/\\s]+$", + "description": "Repository slug used as the seed for fuzzy schedule scattering when --schedule-seed is not provided." + }, "includes": { "type": "array", "description": "Installable package entries, or paths to aw.yml manifests to compose recursively. 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/. A string may end in /* to include supported direct children of one directory. Object entries map a package-relative 'source' to a repository-root-relative 'destination' under .github/workflows/. A wildcard mapping installs each matching file into the destination folder.",