Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/patch-aw-yml-schedule-seed.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/src/content/docs/reference/aw-yml-package-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pkg/cli/add_package_manifest_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type repositoryPackageManifest struct {
License string
Private bool
Experimental bool
ScheduleSeed string
Imports []string
Includes []repositoryPackageInclude
Files []string
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 6 additions & 4 deletions pkg/cli/compile_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,19 @@ 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
}
}
return nil, err
}
config = applyRepositoryManifestDefaults(config, manifest)

compiler := createAndConfigureCompiler(config)
compiler.SetContext(ctx)

// Handle watch mode (early return)
if config.Watch {
Expand Down
26 changes: 18 additions & 8 deletions pkg/cli/compile_repository_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
81 changes: 80 additions & 1 deletion pkg/cli/compile_repository_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}
5 changes: 5 additions & 0 deletions pkg/parser/schemas/aw_manifest_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down