diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 49e9e8a3d3..a3fbd3c13b 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -482,7 +482,7 @@ func runPostProcessingForDirectory( // Skip maintenance workflow generation when using custom --dir option if !config.NoEmit && config.WorkflowDir == "" { absWorkflowDir := getAbsoluteWorkflowDir(workflowsDir, gitRoot) - if err := generateMaintenanceWorkflowWrapper(compiler, workflowDataList, absWorkflowDir, config.Verbose, config.Strict); err != nil { + if err := generateMaintenanceWorkflowWrapper(compiler, workflowDataList, absWorkflowDir, gitRoot, config.Verbose, config.Strict); err != nil { if config.Strict { return err } diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index bf7089f583..f89448d784 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -64,12 +64,23 @@ func generateMaintenanceWorkflowWrapper( compiler *workflow.Compiler, workflowDataList []*workflow.WorkflowData, workflowsDir string, + gitRoot string, verbose bool, strict bool, ) error { compilePostProcessingLog.Print("Generating maintenance workflow") - if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, compiler.GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose); err != nil { + // Load repo-level configuration (optional file). + repoConfig, err := workflow.LoadRepoConfig(gitRoot) + if err != nil { + if strict { + return fmt.Errorf("failed to load repo config: %w", err) + } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to load repo config: %v", err))) + repoConfig = nil + } + + if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, compiler.GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig); err != nil { if strict { return fmt.Errorf("failed to generate maintenance workflow: %w", err) } diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 560570bfd0..d7a0ad877e 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -230,7 +230,15 @@ func ensureMaintenanceWorkflow(verbose bool) error { // Always call GenerateMaintenanceWorkflow even with empty list // This allows it to delete existing maintenance workflow if no workflows have expires initLog.Printf("Generating maintenance workflow for %d workflows", len(workflowDataList)) - if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose); err != nil { + + // Load repo-level configuration (optional; errors are non-fatal during init). + repoConfig, err := workflow.LoadRepoConfig(gitRoot) + if err != nil { + initLog.Printf("Failed to load repo config, using defaults: %v", err) + repoConfig = nil + } + + if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig); err != nil { return fmt.Errorf("failed to generate maintenance workflow: %w", err) } diff --git a/pkg/parser/schema_compiler.go b/pkg/parser/schema_compiler.go index 20dffeeca8..ece435e22c 100644 --- a/pkg/parser/schema_compiler.go +++ b/pkg/parser/schema_compiler.go @@ -25,17 +25,23 @@ var mainWorkflowSchema string //go:embed schemas/mcp_config_schema.json var mcpConfigSchema string +//go:embed schemas/repo_config_schema.json +var RepoConfigSchema string + // validateWithSchema validates frontmatter against a JSON schema // Cached compiled schemas to avoid recompiling on every validation var ( mainWorkflowSchemaOnce sync.Once mcpConfigSchemaOnce sync.Once + repoConfigSchemaOnce sync.Once compiledMainWorkflowSchema *jsonschema.Schema compiledMcpConfigSchema *jsonschema.Schema + compiledRepoConfigSchema *jsonschema.Schema mainWorkflowSchemaError error mcpConfigSchemaError error + repoConfigSchemaError error // Cached parsed schema documents (as any) for suggestion generation. // Parsing the large JSON schema on every error call is expensive; these caches @@ -65,6 +71,14 @@ func getCompiledMcpConfigSchema() (*jsonschema.Schema, error) { return compiledMcpConfigSchema, mcpConfigSchemaError } +// GetCompiledRepoConfigSchema returns the compiled repo config schema, compiling it once and caching +func GetCompiledRepoConfigSchema() (*jsonschema.Schema, error) { + repoConfigSchemaOnce.Do(func() { + compiledRepoConfigSchema, repoConfigSchemaError = compileSchema(RepoConfigSchema, "http://contoso.com/repo-config-schema.json") + }) + return compiledRepoConfigSchema, repoConfigSchemaError +} + // getParsedSchemaDoc returns the parsed (any) representation of a known schema JSON string. // For the two well-known schemas (mainWorkflowSchema, mcpConfigSchema) the result is cached // so the expensive json.Unmarshal is only ever performed once per process lifetime. @@ -189,6 +203,9 @@ func validateWithSchema(frontmatter map[string]any, schemaJSON, context string) case mcpConfigSchema: schemaCompilerLog.Print("Using cached MCP config schema") schema, err = getCompiledMcpConfigSchema() + case RepoConfigSchema: + schemaCompilerLog.Print("Using cached repo config schema") + schema, err = GetCompiledRepoConfigSchema() default: // Fallback for unknown schemas (shouldn't happen in normal operation) // Compile the schema on-the-fly diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json new file mode 100644 index 0000000000..20ab1a7bfb --- /dev/null +++ b/pkg/parser/schemas/repo_config_schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/github/gh-aw/schemas/repo_config_schema.json", + "title": "Repository Configuration Schema", + "description": "JSON Schema for the .github/workflows/aw.json repository-level configuration file for gh-aw agentic workflows", + "version": "1.0.0", + "type": "object", + "additionalProperties": false, + "properties": { + "maintenance": { + "description": "Configuration for the agentic-maintenance workflow. Set to false to disable maintenance entirely, or provide an object to configure it.", + "oneOf": [ + { + "type": "boolean", + "enum": [false], + "description": "Set to false to disable agentic maintenance and refuse features that rely on it (such as expires)." + }, + { + "type": "object", + "description": "Agentic maintenance workflow configuration. Enables generation of agentics-maintenance.yml.", + "additionalProperties": false, + "properties": { + "runs_on": { + "description": "The runner or runners to use for maintenance workflow jobs. Accepts a single label string or an array of labels.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]+$", + "description": "A single runner label for maintenance workflow jobs.", + "examples": ["ubuntu-latest", "ubuntu-slim", "self-hosted"] + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]+$" + }, + "minItems": 1, + "description": "An array of runner labels for maintenance workflow jobs.", + "examples": [ + ["self-hosted", "linux"], + ["self-hosted", "linux", "x64"] + ] + } + ] + } + } + } + ] + } + } +} diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index c4a4ec588d..a5dc68ac29 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" ) @@ -109,10 +110,50 @@ func generateMaintenanceCron(minExpiresDays int) (string, string) { } // GenerateMaintenanceWorkflow generates the agentics-maintenance.yml workflow -// if any workflows use the expires field for discussions or issues -func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir string, version string, actionMode ActionMode, actionTag string, verbose bool) error { +// if any workflows use the expires field for discussions or issues. +// When repoConfig is non-nil and repoConfig.MaintenanceDisabled is true the +// maintenance workflow is deleted and the function returns immediately. +func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir string, version string, actionMode ActionMode, actionTag string, verbose bool, repoConfig *RepoConfig) error { maintenanceLog.Print("Checking if maintenance workflow is needed") + // Respect explicit opt-out from aw.json: maintenance: false + if repoConfig != nil && repoConfig.MaintenanceDisabled { + maintenanceLog.Print("Maintenance disabled via repo config, skipping generation") + + // Warn if any workflow uses expires — those features rely on maintenance + // and will silently become no-ops when it is disabled. + for _, workflowData := range workflowDataList { + if workflowData.SafeOutputs == nil { + continue + } + usesExpires := (workflowData.SafeOutputs.CreateDiscussions != nil && workflowData.SafeOutputs.CreateDiscussions.Expires > 0) || + (workflowData.SafeOutputs.CreateIssues != nil && workflowData.SafeOutputs.CreateIssues.Expires > 0) || + (workflowData.SafeOutputs.CreatePullRequests != nil && workflowData.SafeOutputs.CreatePullRequests.Expires > 0) + if usesExpires { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage( + fmt.Sprintf("Workflow '%s' uses the 'expires' field but maintenance is disabled in aw.json. "+ + "Expiration will not run until maintenance is re-enabled.", workflowData.Name))) + } + } + + maintenanceFile := filepath.Join(workflowDir, "agentics-maintenance.yml") + if _, err := os.Stat(maintenanceFile); err == nil { + maintenanceLog.Printf("Deleting existing maintenance workflow: %s", maintenanceFile) + if err := os.Remove(maintenanceFile); err != nil { + return fmt.Errorf("failed to delete maintenance workflow: %w", err) + } + } + return nil + } + + // Determine the runs-on value to use for all maintenance jobs. + const defaultRunsOn = "ubuntu-slim" + var configuredRunsOn RunsOnValue + if repoConfig != nil && repoConfig.Maintenance != nil { + configuredRunsOn = repoConfig.Maintenance.RunsOn + } + runsOnValue := FormatRunsOn(configuredRunsOn, defaultRunsOn) + // Check if any workflow uses expires field for discussions, issues, or pull requests // and track the minimum expires value to determine schedule frequency hasExpires := false @@ -232,7 +273,7 @@ permissions: {} jobs: close-expired-entities: if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} - runs-on: ubuntu-slim + runs-on: ` + runsOnValue + ` permissions: discussions: write issues: write @@ -305,7 +346,7 @@ jobs: yaml.WriteString(` run_operation: if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && github.event.inputs.operation != 'create_labels' && !github.event.repository.fork }} - runs-on: ubuntu-slim + runs-on: ` + runsOnValue + ` permissions: actions: write contents: write @@ -353,7 +394,7 @@ jobs: yaml.WriteString(` apply_safe_outputs: if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'safe_outputs' && !github.event.repository.fork }} - runs-on: ubuntu-slim + runs-on: ` + runsOnValue + ` permissions: actions: read contents: write @@ -401,7 +442,7 @@ jobs: yaml.WriteString(` create_labels: if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'create_labels' && !github.event.repository.fork }} - runs-on: ubuntu-slim + runs-on: ` + runsOnValue + ` permissions: contents: read issues: write @@ -450,7 +491,7 @@ jobs: yaml.WriteString(` compile-workflows: if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} - runs-on: ubuntu-slim + runs-on: ` + runsOnValue + ` permissions: contents: read issues: write @@ -487,7 +528,7 @@ jobs: zizmor-scan: if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} - runs-on: ubuntu-slim + runs-on: ` + runsOnValue + ` needs: compile-workflows permissions: contents: read @@ -511,7 +552,7 @@ jobs: secret-validation: if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} - runs-on: ubuntu-slim + runs-on: ` + runsOnValue + ` permissions: contents: read steps: diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 8c1677d865..dfa93d292d 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -149,7 +149,7 @@ func TestGenerateMaintenanceWorkflow_WithExpires(t *testing.T) { tmpDir := t.TempDir() // Call GenerateMaintenanceWorkflow - err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false) + err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) // Check error expectation if tt.expectError && err == nil { @@ -238,7 +238,7 @@ func TestGenerateMaintenanceWorkflow_DeletesExistingFile(t *testing.T) { } // Call GenerateMaintenanceWorkflow - err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false) + err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -270,7 +270,7 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -371,7 +371,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { t.Run("release mode with action-tag uses remote ref", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -407,7 +407,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataListWithResolver, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false) + err := GenerateMaintenanceWorkflow(workflowDataListWithResolver, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -426,7 +426,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { t.Run("dev mode ignores action-tag and uses local path", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "v0.47.4", false) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "v0.47.4", false, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -539,7 +539,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("dev mode run_operation uses build from source", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -558,7 +558,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("release mode run_operation uses setup-cli action not gh extension install", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -580,7 +580,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("dev mode compile_workflows uses same codegen as run_operation", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -628,7 +628,7 @@ func TestGenerateMaintenanceWorkflow_SetupCLISHAPinning(t *testing.T) { cache.Set("github/gh-aw/actions/setup", "v1.0.0", "dddddddddddddddddddddddddddddddddddddddd") resolver := NewActionResolver(cache) - err := GenerateMaintenanceWorkflow(workflowDataListWithResolver(resolver), tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false) + err := GenerateMaintenanceWorkflow(workflowDataListWithResolver(resolver), tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -647,3 +647,112 @@ func TestGenerateMaintenanceWorkflow_SetupCLISHAPinning(t *testing.T) { } }) } + +func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { + // makeList returns a fresh workflow data list for each sub-test to avoid + // shared-state issues between parallel or repeated sub-tests. + makeList := func() []*WorkflowData { + return []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 24}, + }, + }, + } + } + + t.Run("custom string runs_on is used in all jobs", func(t *testing.T) { + tmpDir := t.TempDir() + cfg := &RepoConfig{ + Maintenance: &MaintenanceConfig{RunsOn: RunsOnValue{"my-custom-runner"}}, + } + err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + if !strings.Contains(yaml, "runs-on: my-custom-runner") { + t.Errorf("Expected 'runs-on: my-custom-runner' in generated workflow, got:\n%s", yaml) + } + // Default runner must not appear + if strings.Contains(yaml, "runs-on: ubuntu-slim") { + t.Errorf("Generated workflow must not use default runner 'ubuntu-slim' when overridden; got:\n%s", yaml) + } + }) + + t.Run("array runs_on is used in all jobs", func(t *testing.T) { + tmpDir := t.TempDir() + cfg := &RepoConfig{ + Maintenance: &MaintenanceConfig{RunsOn: RunsOnValue{"self-hosted", "linux"}}, + } + err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + if !strings.Contains(yaml, `runs-on: ["self-hosted","linux"]`) { + t.Errorf("Expected array runs-on in generated workflow, got:\n%s", yaml) + } + }) + + t.Run("maintenance disabled deletes existing file", func(t *testing.T) { + tmpDir := t.TempDir() + // Create a pre-existing maintenance file to be deleted + maintenanceFile := filepath.Join(tmpDir, "agentics-maintenance.yml") + if err := os.WriteFile(maintenanceFile, []byte("existing content"), 0o600); err != nil { + t.Fatalf("Failed to write pre-existing file: %v", err) + } + cfg := &RepoConfig{MaintenanceDisabled: true} + err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if _, statErr := os.Stat(maintenanceFile); !os.IsNotExist(statErr) { + t.Errorf("Expected maintenance workflow to be deleted when disabled, but file still exists") + } + }) + + t.Run("maintenance disabled skips generation even with expires", func(t *testing.T) { + tmpDir := t.TempDir() + cfg := &RepoConfig{MaintenanceDisabled: true} + err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if _, statErr := os.Stat(filepath.Join(tmpDir, "agentics-maintenance.yml")); !os.IsNotExist(statErr) { + t.Errorf("Expected no maintenance workflow to be generated when disabled") + } + }) + + t.Run("maintenance disabled with expires emits warning (no error)", func(t *testing.T) { + tmpDir := t.TempDir() + // Workflow with expires configured – maintenance is disabled in aw.json. + list := []*WorkflowData{ + { + Name: "my-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + cfg := &RepoConfig{MaintenanceDisabled: true} + // The function must succeed (no error), even though a warning is printed. + err := GenerateMaintenanceWorkflow(list, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + if err != nil { + t.Fatalf("Expected no error when maintenance is disabled with expires, got: %v", err) + } + // The maintenance workflow must not be generated. + if _, statErr := os.Stat(filepath.Join(tmpDir, "agentics-maintenance.yml")); !os.IsNotExist(statErr) { + t.Errorf("Expected no maintenance workflow file when maintenance is disabled") + } + }) +} diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go new file mode 100644 index 0000000000..6579799cad --- /dev/null +++ b/pkg/workflow/repo_config.go @@ -0,0 +1,192 @@ +// Package workflow provides the repo-level configuration loader for aw.json. +// +// This file loads and validates .github/workflows/aw.json, which provides +// repository-level settings for agentic workflows such as customising the +// agentics-maintenance runner. +// +// Configuration reference: +// +// { +// "maintenance": { // enables generation of agentics-maintenance.yml +// "runs_on": "custom runner" // string or string[] – runner label(s) for all +// } // maintenance jobs (default: ubuntu-slim) +// } +// +// { +// "maintenance": false // disables agentic maintenance entirely +// } +package workflow + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/parser" +) + +var repoConfigLog = logger.New("workflow:repo_config") + +// RepoConfigFileName is the path of the repository-level configuration file +// relative to the git root. +const RepoConfigFileName = ".github/workflows/aw.json" + +// RunsOnValue is a JSON-deserializable type for the runs_on field in aw.json. +// It accepts either a single runner label string or an array of runner label strings. +// When unmarshalled, a plain string is normalised to a single-element slice so the +// rest of the code works with a uniform []string type. +type RunsOnValue []string + +// UnmarshalJSON implements json.Unmarshaler, accepting either a JSON string or +// a JSON array of strings for the runs_on field. +func (r *RunsOnValue) UnmarshalJSON(data []byte) error { + // Try plain string first (runs_on: "ubuntu-latest") + var s string + if err := json.Unmarshal(data, &s); err == nil { + *r = RunsOnValue{s} + return nil + } + + // Try array of strings (runs_on: ["self-hosted", "linux"]) + var ss []string + if err := json.Unmarshal(data, &ss); err != nil { + return fmt.Errorf("runs_on must be a string or array of strings: %w", err) + } + *r = RunsOnValue(ss) + return nil +} + +// MaintenanceConfig holds maintenance-workflow-specific settings from aw.json. +type MaintenanceConfig struct { + // RunsOn is the runner label or labels used for all jobs in agentics-maintenance.yml. + RunsOn RunsOnValue `json:"runs_on,omitempty"` +} + +// RepoConfig is the parsed representation of aw.json. +type RepoConfig struct { + // MaintenanceDisabled is true when maintenance has been explicitly set to false + // in aw.json, disabling agentic-maintenance generation and any features that + // depend on it (such as expires). + MaintenanceDisabled bool + + // Maintenance holds maintenance-specific settings when maintenance is enabled + // and an object was provided (nil when maintenance is not configured or is + // disabled). + Maintenance *MaintenanceConfig +} + +// UnmarshalJSON implements json.Unmarshaler to handle the polymorphic maintenance +// field, which can be either the boolean false (disable) or a configuration object. +func (r *RepoConfig) UnmarshalJSON(data []byte) error { + // Use an intermediate struct with json.RawMessage to defer maintenance parsing. + var raw struct { + Maintenance json.RawMessage `json:"maintenance,omitempty"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + if len(raw.Maintenance) == 0 || string(raw.Maintenance) == "null" { + return nil + } + + // Try boolean first: maintenance: false disables the feature. + var b bool + if err := json.Unmarshal(raw.Maintenance, &b); err == nil { + r.MaintenanceDisabled = !b + return nil + } + + // Otherwise deserialise as an object with JSON annotations. + var mc MaintenanceConfig + if err := json.Unmarshal(raw.Maintenance, &mc); err != nil { + return fmt.Errorf("invalid maintenance configuration: %w", err) + } + r.Maintenance = &mc + return nil +} + +// LoadRepoConfig loads and validates .github/workflows/aw.json from the +// provided git root directory. The function returns a non-nil *RepoConfig +// with default values when the file does not exist (the file is optional). +// An error is returned only when the file exists but cannot be read or fails +// schema validation. +func LoadRepoConfig(gitRoot string) (*RepoConfig, error) { + configPath := filepath.Join(gitRoot, RepoConfigFileName) + repoConfigLog.Printf("Loading repo config from %s", configPath) + + data, err := os.ReadFile(filepath.Clean(configPath)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + repoConfigLog.Print("Repo config file not found, using defaults") + return &RepoConfig{}, nil + } + return nil, fmt.Errorf("failed to read %s: %w", RepoConfigFileName, err) + } + + // Validate against the embedded JSON schema before deserialising. + if err := validateRepoConfigJSON(data, configPath); err != nil { + return nil, err + } + + // Deserialise into typed structs via JSON annotations. + var cfg RepoConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", RepoConfigFileName, err) + } + + return &cfg, nil +} + +// validateRepoConfigJSON validates raw JSON bytes against the repo config schema. +func validateRepoConfigJSON(data []byte, filePath string) error { + schema, err := parser.GetCompiledRepoConfigSchema() + if err != nil { + return fmt.Errorf("failed to compile repo config schema: %w", err) + } + + var doc any + if err := json.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("failed to parse %s as JSON: %w", filePath, err) + } + + if err := schema.Validate(doc); err != nil { + return fmt.Errorf("invalid %s: %w", RepoConfigFileName, err) + } + + return nil +} + +// FormatRunsOn serialises a RunsOnValue to a YAML-compatible string that can +// be inlined directly after "runs-on: " in a generated workflow. +// +// - empty / nil → defaultRunsOn is returned +// - single label → the label string (e.g. "ubuntu-latest") +// - multiple labels → JSON-encoded flow sequence, e.g. ["self-hosted","linux"] +// +// For multi-label values json.Marshal is used so that any characters that are +// special in YAML or JSON (quotes, backslashes, …) are properly escaped. +// The schema already forbids newlines and control characters, providing a +// defence-in-depth against YAML injection. +func FormatRunsOn(runsOn RunsOnValue, defaultRunsOn string) string { + if len(runsOn) == 0 { + return defaultRunsOn + } + if len(runsOn) == 1 { + if runsOn[0] == "" { + return defaultRunsOn + } + return runsOn[0] + } + // Multiple labels: use json.Marshal to produce a properly-escaped YAML + // flow sequence. A JSON array is valid YAML flow sequence notation. + encoded, err := json.Marshal([]string(runsOn)) + if err != nil { + // []string marshalling never fails; fall back to the default just in case. + return defaultRunsOn + } + return string(encoded) +} diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go new file mode 100644 index 0000000000..36a447114a --- /dev/null +++ b/pkg/workflow/repo_config_test.go @@ -0,0 +1,137 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadRepoConfig_FileNotFound(t *testing.T) { + dir := t.TempDir() + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "missing aw.json should return default config without error") + assert.False(t, cfg.MaintenanceDisabled, "maintenance should be enabled by default") + assert.Nil(t, cfg.Maintenance, "maintenance config should be nil when file is absent") +} + +func TestLoadRepoConfig_MaintenanceFalse(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": false}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json should load without error") + assert.True(t, cfg.MaintenanceDisabled, "maintenance should be disabled") + assert.Nil(t, cfg.Maintenance, "maintenance config should be nil when disabled") +} + +func TestLoadRepoConfig_MaintenanceWithStringRunsOn(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {"runs_on": "custom-runner"}}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json should load without error") + assert.False(t, cfg.MaintenanceDisabled, "maintenance should not be disabled") + require.NotNil(t, cfg.Maintenance, "maintenance config should be set") + assert.Equal(t, RunsOnValue{"custom-runner"}, cfg.Maintenance.RunsOn, "string runs_on should be normalised to a single-element RunsOnValue") +} + +func TestLoadRepoConfig_MaintenanceWithArrayRunsOn(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {"runs_on": ["self-hosted", "linux"]}}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json should load without error") + assert.False(t, cfg.MaintenanceDisabled, "maintenance should not be disabled") + require.NotNil(t, cfg.Maintenance, "maintenance config should be set") + assert.Equal(t, RunsOnValue{"self-hosted", "linux"}, cfg.Maintenance.RunsOn, "array runs_on should be deserialised as RunsOnValue") +} + +func TestLoadRepoConfig_EmptyObject(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "empty aw.json should load without error") + assert.False(t, cfg.MaintenanceDisabled, "maintenance should be enabled by default") + assert.Nil(t, cfg.Maintenance, "maintenance config should be nil when not specified") +} + +func TestLoadRepoConfig_MaintenanceEmptyObject(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {}}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "aw.json with empty maintenance object should load without error") + assert.False(t, cfg.MaintenanceDisabled, "maintenance should not be disabled") + require.NotNil(t, cfg.Maintenance, "maintenance config should be set") + assert.Empty(t, cfg.Maintenance.RunsOn, "runs_on should be empty when not specified") +} + +func TestLoadRepoConfig_InvalidJSON(t *testing.T) { + dir := t.TempDir() + writeAWJSONRaw(t, dir, `not-json`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "invalid JSON should return an error") +} + +func TestLoadRepoConfig_SchemaViolation(t *testing.T) { + dir := t.TempDir() + // "maintenance: true" is not allowed by the schema (only false or object) + writeAWJSON(t, dir, `{"maintenance": true}`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "schema violation should return an error") +} + +func TestLoadRepoConfig_UnknownProperty(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"unknown_property": "value"}`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "unknown property should fail schema validation (additionalProperties: false)") +} + +// TestFormatRunsOn tests the YAML serialisation of runs-on values. +func TestFormatRunsOn(t *testing.T) { + const def = "ubuntu-slim" + + tests := []struct { + name string + runsOn RunsOnValue + expected string + }{ + {"nil uses default", nil, def}, + {"empty slice uses default", RunsOnValue{}, def}, + {"empty string element uses default", RunsOnValue{""}, def}, + {"single label", RunsOnValue{"custom-runner"}, "custom-runner"}, + {"single self-hosted label", RunsOnValue{"self-hosted"}, "self-hosted"}, + {"multi-label array", RunsOnValue{"self-hosted", "linux"}, `["self-hosted","linux"]`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatRunsOn(tt.runsOn, def) + assert.Equal(t, tt.expected, got, "FormatRunsOn should return expected YAML value") + }) + } +} + +// writeAWJSON creates .github/workflows/aw.json with the given JSON content. +func writeAWJSON(t *testing.T, gitRoot, content string) { + t.Helper() + writeAWJSONRaw(t, gitRoot, content) +} + +// writeAWJSONRaw creates .github/workflows/aw.json with raw (possibly invalid) content. +func writeAWJSONRaw(t *testing.T, gitRoot, content string) { + t.Helper() + dir := filepath.Join(gitRoot, ".github", "workflows") + require.NoError(t, os.MkdirAll(dir, 0o755), "failed to create workflows dir") + require.NoError(t, os.WriteFile(filepath.Join(dir, "aw.json"), []byte(content), 0o600), "failed to write aw.json") +}