diff --git a/.changeset/minor-add-workflow-docs-metadata.md b/.changeset/minor-add-workflow-docs-metadata.md new file mode 100644 index 00000000000..2ead9c4c861 --- /dev/null +++ b/.changeset/minor-add-workflow-docs-metadata.md @@ -0,0 +1,5 @@ +--- +"gh-aw": minor +--- + +Add `metadata.docs` workflow frontmatter for a canonical HTTPS documentation URL. diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 7527f13adbe..e4a7762f652 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -89,7 +89,10 @@ skills: [] # limited to 1024 characters. # (optional) metadata: - {} + # Optional absolute HTTPS URL for human-facing workflow documentation. Preserved + # in generated lock-file metadata without being fetched during compilation. + # (optional) + docs: "https://docs.example.com/automation/repository-health" # Workflow specifications to import. Supports array form (list of paths) or object # form with 'aw' (agentic workflow paths) subfield. Path resolution: (1) relative diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index df02b0c4880..0e83c6ff13f 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -57,9 +57,12 @@ metadata: author: John Doe version: 1.0.0 category: automation + docs: https://docs.example.com/automation/repository-health ``` Keys must be 1–64 characters; values are string-only, up to 1024 characters. +`metadata.docs`, when present, must be an absolute HTTPS URL. The compiler preserves +it in generated lock-file metadata without fetching it or changing workflow execution. ### Trigger Events (`on:`) diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index be3bfb1607e..88ba2de31a1 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -59,6 +59,7 @@ type importAccumulator struct { sandboxAgentRuntimeInstall *bool // false if any import sets sandbox.agent.runtime-install: false caches []string features []map[string]any + metadataDocs string models []map[string][]string // model alias maps from each imported file (appended in import order) modelPolicies []map[string][]string // model policy sets from each imported file (appended in import order) modelCosts []map[string]any // model pricing overlays from each imported file (appended in import order) @@ -395,6 +396,13 @@ func (acc *importAccumulator) extractConfigFields(fm map[string]any, fullPath st acc.extractFirstWinsJSONField(fm, fullPath, "max-turn-cache-misses", &acc.mergedMaxTurnCacheMisses) acc.extractFirstWinsJSONField(fm, fullPath, "max-ai-credits", &acc.mergedMaxAICredits) acc.extractFirstWinsJSONField(fm, fullPath, "max-daily-ai-credits", &acc.mergedMaxDailyAICredits) + if acc.metadataDocs == "" { + if metadata, ok := fm["metadata"].(map[string]any); ok { + if docs, ok := metadata["docs"].(string); ok { + acc.metadataDocs = strings.TrimSpace(docs) + } + } + } acc.appendJSONBuilderField(fm, "mcp-servers", "{}", &acc.mcpServersBuilder) acc.plugins = append(acc.plugins, parseStringSliceField(fm["plugins"], false)...) @@ -991,6 +999,7 @@ func (acc *importAccumulator) buildImportsResult() *ImportsResult { MergedEnv: acc.envBuilder.String(), MergedEnvSources: acc.envSources, MergedFeatures: acc.features, + MergedMetadataDocs: acc.metadataDocs, MergedModels: acc.models, MergedModelPolicies: acc.modelPolicies, MergedModelCosts: acc.modelCosts, diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 4332b37782d..e7f8984e70e 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -63,6 +63,7 @@ type ImportsResult struct { MergedEnv string // Merged env configuration from all imports (JSON format) MergedEnvSources map[string]string // env var name → source import path (for conflict detection and lock file header listing) MergedFeatures []map[string]any // Merged features configuration from all imports (parsed YAML structures) + MergedMetadataDocs string // metadata.docs from the first imported workflow that defines it MergedModels []map[string][]string // Merged model alias definitions from all imports (first import to define a key wins among imports) MergedModelPolicies []map[string][]string // Merged model policy sets from all imports (models.allowed/blocked) MergedModelCosts []map[string]any // Merged model pricing overlays (models.json provider structure) from all imports diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index e9adfe828a0..b915d8ccece 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -46,6 +46,40 @@ func TestValidateMainWorkflowFrontmatter_RejectsUnsupportedTopLevelFields(t *tes } } +func TestValidateMainWorkflowFrontmatter_MetadataDocs(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + docs any + wantErr bool + }{ + {name: "absolute HTTPS URL", docs: "https://docs.example.com/workflows/repo-health"}, + {name: "repository documentation URL", docs: "https://github.com/OWNER/REPO/blob/main/docs/workflows/repository-health.md"}, + {name: "relative path", docs: "docs/workflows/repo-health.md", wantErr: true}, + {name: "non-HTTPS URL", docs: "http://docs.example.com/workflows/repo-health", wantErr: true}, + {name: "JavaScript URL", docs: "javascript:alert(1)", wantErr: true}, + {name: "missing host", docs: "https://:", wantErr: true}, + {name: "invalid port", docs: "https://example.com:99999", wantErr: true}, + {name: "empty string", docs: "", wantErr: true}, + {name: "object", docs: map[string]any{"user": "https://docs.example.com/user-guide"}, wantErr: true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(map[string]any{ + "on": "workflow_dispatch", + "metadata": map[string]any{ + "docs": tt.docs, + }, + }, "workflow.md") + if (err != nil) != tt.wantErr { + t.Fatalf("metadata.docs validation error = %v, wantErr %t", err, tt.wantErr) + } + }) + } +} + func TestValidateMainWorkflowFrontmatter_Plugins(t *testing.T) { valid := map[string]any{ "on": "workflow_dispatch", diff --git a/pkg/parser/schema_validation.go b/pkg/parser/schema_validation.go index 37f5bbdbc9d..b541ad37ae6 100644 --- a/pkg/parser/schema_validation.go +++ b/pkg/parser/schema_validation.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "maps" + "net/url" + "strconv" "strings" "github.com/github/gh-aw/pkg/constants" @@ -130,6 +132,9 @@ func ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter map[string if err := validateUnsupportedJobInputs(filtered); err != nil { return err } + if err := validateMetadataDocs(filtered); err != nil { + return err + } // Then run the standard schema validation with location if err := validateWithSchemaAndLocation(filtered, mainWorkflowSchema, "main workflow file", filePath); err != nil { @@ -140,6 +145,28 @@ func ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter map[string return validateEngineSpecificRules(filtered) } +func validateMetadataDocs(frontmatter map[string]any) error { + metadata, ok := frontmatter["metadata"].(map[string]any) + if !ok { + return nil + } + docs, ok := metadata["docs"].(string) + if !ok { + return nil + } + parsed, err := url.ParseRequestURI(docs) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" { + return errors.New("metadata.docs must be a valid absolute HTTPS URL") + } + if port := parsed.Port(); port != "" { + number, err := strconv.ParseUint(port, 10, 16) + if err != nil || number == 0 { + return errors.New("metadata.docs must be a valid absolute HTTPS URL") + } + } + return nil +} + // ValidateIncludedFileFrontmatterWithSchemaAndLocation validates included file frontmatter with file location info func ValidateIncludedFileFrontmatterWithSchemaAndLocation(frontmatter map[string]any, filePath string) error { schemaValidationLog.Printf("Validating included file frontmatter: file=%s, fields=%d", filePath, len(frontmatter)) @@ -158,6 +185,9 @@ func ValidateIncludedFileFrontmatterWithSchemaAndLocation(frontmatter map[string if err := validateSharedWorkflowFields(filtered); err != nil { return err } + if err := validateMetadataDocs(filtered); err != nil { + return err + } // To validate shared workflows against the main schema, we temporarily add an 'on' field tempFrontmatter := make(map[string]any) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index a12f33ce937..adb4e4a2350 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -150,6 +150,16 @@ "metadata": { "type": "object", "description": "Optional metadata field for storing custom key-value pairs compatible with the custom agent spec. Key names are limited to 64 characters, and values are limited to 1024 characters.", + "properties": { + "docs": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://\\S+$", + "description": "Optional absolute HTTPS URL for human-facing workflow documentation. Preserved in generated lock-file metadata without being fetched during compilation.", + "examples": ["https://docs.example.com/automation/repository-health"] + } + }, "patternProperties": { "^.{1,64}$": { "type": "string", diff --git a/pkg/workflow/compiler_orchestrator_workflow_test.go b/pkg/workflow/compiler_orchestrator_workflow_test.go index a70bd4f210b..be01ff799e2 100644 --- a/pkg/workflow/compiler_orchestrator_workflow_test.go +++ b/pkg/workflow/compiler_orchestrator_workflow_test.go @@ -22,8 +22,8 @@ func TestBuildInitialWorkflowData_BasicFields(t *testing.T) { // Mock frontmatter result frontmatterResult := &parser.FrontmatterResult{ - Frontmatter: map[string]any{"description": "Test workflow", "source": "test-source"}, - FrontmatterLines: []string{"description: Test workflow", "source: test-source"}, + Frontmatter: map[string]any{"description": "Test workflow", "metadata": map[string]any{"docs": "https://docs.example.com/test-workflow"}, "source": "test-source"}, + FrontmatterLines: []string{"description: Test workflow", "metadata:", " docs: https://docs.example.com/test-workflow", "source: test-source"}, Markdown: "# Test\n\nContent", } @@ -68,6 +68,7 @@ func TestBuildInitialWorkflowData_BasicFields(t *testing.T) { assert.Equal(t, "Test Workflow", workflowData.Name) assert.Equal(t, "Test Frontmatter Name", workflowData.FrontmatterName) assert.Equal(t, "Test workflow", workflowData.Description) + assert.Equal(t, "https://docs.example.com/test-workflow", workflowData.Docs) assert.Equal(t, "test-source", workflowData.Source) assert.Equal(t, "TRACKER-123", workflowData.TrackerID) assert.Equal(t, []string{"/imported/file"}, workflowData.ImportedFiles) diff --git a/pkg/workflow/compiler_yaml_header.go b/pkg/workflow/compiler_yaml_header.go index 0ec8b121c27..8fe3268545f 100644 --- a/pkg/workflow/compiler_yaml_header.go +++ b/pkg/workflow/compiler_yaml_header.go @@ -48,6 +48,7 @@ func (c *Compiler) generateWorkflowHeader(yaml *strings.Builder, data *WorkflowD agentInfo.EngineVersions = collectEngineVersionsForMetadata(data, c.engineRegistry) agentInfo.AgentImageRunner = resolveAgentImageRunnerIdentifier(data.RawFrontmatter) metadata := GenerateLockMetadata(LockHashInfo{FrontmatterHash: frontmatterHash, BodyHash: bodyHash}, data.StopTime, c.effectiveStrictMode(data.RawFrontmatter), agentInfo) + metadata.Docs = data.Docs if metadata.CompilerVersion == "" && c.GetActionTag() != "" { metadata.CompilerVersion = c.GetVersion() } diff --git a/pkg/workflow/compiler_yaml_test.go b/pkg/workflow/compiler_yaml_test.go index 2616d7a9443..0a9aae68876 100644 --- a/pkg/workflow/compiler_yaml_test.go +++ b/pkg/workflow/compiler_yaml_test.go @@ -1702,6 +1702,104 @@ Test prompt. } } +func TestCompileWorkflowMetadataIncludesDocs(t *testing.T) { + tmpDir := testutil.TempDir(t, "lock-metadata-docs") + workflowPath := filepath.Join(tmpDir, "docs.md") + workflowContent := `--- +engine: copilot +metadata: + docs: https://docs.example.com/automation/repository-health +on: issues +--- +# Test Workflow + +Test prompt. +` + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0o644); err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + if err := NewCompiler().CompileWorkflow(workflowPath); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockContent, err := os.ReadFile(strings.TrimSuffix(workflowPath, ".md") + ".lock.yml") + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + metadata, _, err := ExtractMetadataFromLockFile(string(lockContent)) + if err != nil { + t.Fatalf("Failed to extract lock metadata: %v", err) + } + if metadata == nil { + t.Fatal("Expected lock metadata") + } + if metadata.Docs != "https://docs.example.com/automation/repository-health" { + t.Errorf("Docs = %q, want documentation URL", metadata.Docs) + } +} + +func TestCompileWorkflowMetadataDocsImportPrecedence(t *testing.T) { + for _, tt := range []struct { + name string + mainMetadata string + want string + }{ + {name: "first import fallback", want: "https://docs.example.com/first"}, + {name: "main workflow wins", mainMetadata: "metadata:\n docs: https://docs.example.com/main\n", want: "https://docs.example.com/main"}, + } { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "lock-metadata-docs-import") + for _, imported := range []struct { + name string + docs string + }{ + {name: "first.md", docs: "https://docs.example.com/first"}, + {name: "second.md", docs: "https://docs.example.com/second"}, + } { + content := fmt.Sprintf("---\nmetadata:\n docs: %s\n---\n\nImported prompt.\n", imported.docs) + if err := os.WriteFile(filepath.Join(tmpDir, imported.name), []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write imported workflow: %v", err) + } + } + + workflowPath := filepath.Join(tmpDir, "main.md") + workflowContent := fmt.Sprintf(`--- +engine: copilot +imports: + - first.md + - second.md +%son: issues +--- +# Test Workflow + +Test prompt. +`, tt.mainMetadata) + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0o644); err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + if err := NewCompiler().CompileWorkflow(workflowPath); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockContent, err := os.ReadFile(strings.TrimSuffix(workflowPath, ".md") + ".lock.yml") + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + metadata, _, err := ExtractMetadataFromLockFile(string(lockContent)) + if err != nil { + t.Fatalf("Failed to extract lock metadata: %v", err) + } + if metadata == nil { + t.Fatal("Expected lock metadata") + } + if metadata.Docs != tt.want { + t.Fatalf("Docs = %q, want %q", metadata.Docs, tt.want) + } + }) + } +} + func TestCompileWorkflowMetadataIncludesEngineVersionsAndRunnerIdentifier(t *testing.T) { tmpDir := testutil.TempDir(t, "lock-metadata-engine-versions") diff --git a/pkg/workflow/frontmatter_extraction_metadata.go b/pkg/workflow/frontmatter_extraction_metadata.go index b66a7b1d784..5713bcbf222 100644 --- a/pkg/workflow/frontmatter_extraction_metadata.go +++ b/pkg/workflow/frontmatter_extraction_metadata.go @@ -55,6 +55,20 @@ func (c *Compiler) extractDescription(frontmatter map[string]any) string { return "" } +// extractMetadataDocs extracts metadata.docs from frontmatter. +func (c *Compiler) extractMetadataDocs(frontmatter map[string]any) string { + metadata, ok := frontmatter["metadata"].(map[string]any) + if !ok { + return "" + } + + if strValue, ok := metadata["docs"].(string); ok { + return strings.TrimSpace(strValue) + } + + return "" +} + // extractSource extracts the source field from frontmatter func (c *Compiler) extractSource(frontmatter map[string]any) string { value, exists := frontmatter["source"] diff --git a/pkg/workflow/frontmatter_types_test.go b/pkg/workflow/frontmatter_types_test.go index 6f8a781f0a2..86493e79901 100644 --- a/pkg/workflow/frontmatter_types_test.go +++ b/pkg/workflow/frontmatter_types_test.go @@ -253,10 +253,13 @@ func TestParseFrontmatterConfig(t *testing.T) { frontmatter := map[string]any{ "name": "full-workflow", "description": "A complete workflow", - "engine": "copilot", - "source": "owner/repo/path@main", - "redirect": "owner/repo/new-path@main", - "tracker-id": "test-tracker-123", + "metadata": map[string]any{ + "docs": "https://docs.example.com/full-workflow", + }, + "engine": "copilot", + "source": "owner/repo/path@main", + "redirect": "owner/repo/new-path@main", + "tracker-id": "test-tracker-123", "tools": map[string]any{ "bash": map[string]any{ "enabled": true, @@ -287,6 +290,9 @@ func TestParseFrontmatterConfig(t *testing.T) { if config.Description != "A complete workflow" { t.Errorf("Description = %q, want %q", config.Description, "A complete workflow") } + if config.Metadata["docs"] != "https://docs.example.com/full-workflow" { + t.Errorf("Metadata docs = %q, want documentation URL", config.Metadata["docs"]) + } if config.Engine != "copilot" { t.Errorf("Engine = %q, want %q", config.Engine, "copilot") diff --git a/pkg/workflow/lock_schema.go b/pkg/workflow/lock_schema.go index ca79cb77eda..6fdf485d06e 100644 --- a/pkg/workflow/lock_schema.go +++ b/pkg/workflow/lock_schema.go @@ -38,6 +38,7 @@ type LockMetadata struct { BodyHash string `json:"body_hash,omitempty"` StopTime string `json:"stop_time,omitempty"` CompilerVersion string `json:"compiler_version,omitempty"` + Docs string `json:"docs,omitempty"` Strict bool `json:"strict,omitempty"` // AgentMetadataInfo is embedded so agent fields are declared once and // serialized inline in the lock metadata JSON. diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 124fd6a3602..6ab4821da18 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -29,6 +29,10 @@ func (c *Compiler) buildInitialWorkflowData( agentFile = "" agentImportSpec = "" } + docs := c.extractMetadataDocs(result.Frontmatter) + if docs == "" { + docs = importsResult.MergedMetadataDocs + } workflowData := &WorkflowData{ Name: toolsResult.workflowName, @@ -38,6 +42,7 @@ func (c *Compiler) buildInitialWorkflowData( FrontmatterFieldLines: result.FieldLines, RawMarkdown: result.Markdown, Description: c.extractDescription(result.Frontmatter), + Docs: docs, Source: c.extractSource(result.Frontmatter), Redirect: c.extractRedirect(result.Frontmatter), TrackerID: toolsResult.trackerID, diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index d22a1313ae4..42e626c008d 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -48,6 +48,7 @@ type WorkflowData struct { FrontmatterFieldLines map[string]int // absolute 1-based line numbers of top-level frontmatter keys in the source file (populated by parser) RawMarkdown string // raw markdown body before include expansion, used for frontmatter hash computation without re-reading the file Description string // optional description rendered as comment in lock file + Docs string // optional human-facing documentation URL preserved in lock metadata Source string // optional source field (owner/repo@ref/path) rendered as comment in lock file Redirect string // optional redirect field describing a moved workflow location TrackerID string // optional tracker identifier for created assets (min 8 chars, alphanumeric + hyphens/underscores) diff --git a/schema-demos/schema-demo-metadata.md b/schema-demos/schema-demo-metadata.md index caf5c566ba0..34a6702c84d 100644 --- a/schema-demos/schema-demo-metadata.md +++ b/schema-demos/schema-demo-metadata.md @@ -8,6 +8,7 @@ engine: codex metadata: author: schema-coverage version: "1.0.0" + docs: https://docs.example.com/automation/repository-health timeout-minutes: 5 --- diff --git a/scripts/generate-schema-docs.js b/scripts/generate-schema-docs.js index 6e59fa18b65..d06992caee9 100755 --- a/scripts/generate-schema-docs.js +++ b/scripts/generate-schema-docs.js @@ -138,6 +138,7 @@ function getExampleValue(prop, propName = "") { if (propName === "github-token") return "${{ secrets.GITHUB_TOKEN }}"; if (propName === "name") return "My Workflow"; if (propName === "description") return "Description of the workflow"; + if (propName === "docs") return "https://docs.example.com/automation/repository-health"; return "example-value"; case "number": case "integer":