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
2 changes: 1 addition & 1 deletion pkg/cli/compile_pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 12 additions & 1 deletion pkg/cli/compile_post_processing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
10 changes: 9 additions & 1 deletion pkg/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
17 changes: 17 additions & 0 deletions pkg/parser/schema_compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions pkg/parser/schemas/repo_config_schema.json
Original file line number Diff line number Diff line change
@@ -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"]
]
}
]
}
}
}
]
}
}
}
59 changes: 50 additions & 9 deletions pkg/workflow/maintenance_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strconv"
"strings"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/logger"
)

Expand Down Expand Up @@ -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
}
Comment on lines +119 to +147
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

When repoConfig.MaintenanceDisabled is true, the function returns early even if workflows contain expires configs. That means expires will silently stop working (no maintenance workflow will run) which conflicts with the PR description’s claim that dependent features are “suppressed/refused”. Either update the behavior to emit an error/warning (especially in strict mode) when expires is present while maintenance is disabled, or adjust the description/docs to reflect that expires becomes a no-op.

Copilot uses AI. Check for mistakes.

// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading