From 87ad085071a27fa2ac650edb414cd310e689ab96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:46:54 +0000 Subject: [PATCH 1/3] Initial plan From c4d09aaa9317725fcefa11d2f0133c79bab0ab0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:04:18 +0000 Subject: [PATCH 2/3] Add workflow documentation metadata Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/frontmatter-full.md | 5 +++ .../src/content/docs/reference/frontmatter.md | 8 +++++ pkg/parser/schema_test.go | 30 ++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 8 +++++ .../compiler_orchestrator_workflow_test.go | 5 +-- pkg/workflow/compiler_yaml_header.go | 1 + pkg/workflow/compiler_yaml_test.go | 36 +++++++++++++++++++ .../frontmatter_extraction_metadata.go | 14 ++++++++ pkg/workflow/frontmatter_types.go | 7 ++-- pkg/workflow/frontmatter_types_test.go | 16 +++++---- pkg/workflow/lock_schema.go | 1 + pkg/workflow/workflow_builder.go | 1 + pkg/workflow/workflow_data.go | 1 + schema-demos/schema-demo-documentation.md | 30 ++++++++++++++++ scripts/generate-schema-docs.js | 1 + 15 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 schema-demos/schema-demo-documentation.md diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 7527f13adbe..fe824aa3481 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -28,6 +28,11 @@ name: "My Workflow" # (optional) description: "Description of the workflow" +# Optional absolute HTTPS URL for human-facing workflow documentation. Preserved +# in generated lock-file metadata without being fetched during compilation. +# (optional) +documentation: "https://docs.example.com/automation/repository-health" + # Optional emoji to represent the workflow visually in listings and UI surfaces. # (optional) emoji: "example-value" diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index df02b0c4880..7555e144dc0 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -32,6 +32,14 @@ Provides a human-readable description of the workflow rendered as a comment in t description: "Workflow that analyzes pull requests and provides feedback" ``` +### Documentation Link (`documentation:`) + +Optional absolute HTTPS URL for human-facing workflow documentation. The compiler preserves the link in generated lock-file metadata without fetching it or changing workflow execution. + +```yaml wrap +documentation: "https://docs.example.com/automation/repository-health" +``` + ### Emoji (`emoji:`) An optional emoji to represent the workflow visually, for example in listings and UI surfaces. diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index e9adfe828a0..3649ecf21f7 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -46,6 +46,36 @@ func TestValidateMainWorkflowFrontmatter_RejectsUnsupportedTopLevelFields(t *tes } } +func TestValidateMainWorkflowFrontmatter_Documentation(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + documentation any + wantErr bool + }{ + {name: "absolute HTTPS URL", documentation: "https://docs.example.com/workflows/repo-health"}, + {name: "repository documentation URL", documentation: "https://github.com/OWNER/REPO/blob/main/docs/workflows/repository-health.md"}, + {name: "relative path", documentation: "docs/workflows/repo-health.md", wantErr: true}, + {name: "non-HTTPS URL", documentation: "http://docs.example.com/workflows/repo-health", wantErr: true}, + {name: "JavaScript URL", documentation: "javascript:alert(1)", wantErr: true}, + {name: "empty string", documentation: "", wantErr: true}, + {name: "object", documentation: 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", + "documentation": tt.documentation, + }, "workflow.md") + if (err != nil) != tt.wantErr { + t.Fatalf("documentation 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/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index a12f33ce937..78205a12759 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -20,6 +20,14 @@ "description": "Optional workflow description that is rendered as a comment in the generated GitHub Actions YAML file (.lock.yml)", "examples": ["Quickstart for using the GitHub Actions library"] }, + "documentation": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://[^/?#\\s]+(?:[/?#][^\\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"] + }, "emoji": { "type": "string", "description": "Optional emoji to represent the workflow visually in listings and UI surfaces.", diff --git a/pkg/workflow/compiler_orchestrator_workflow_test.go b/pkg/workflow/compiler_orchestrator_workflow_test.go index a70bd4f210b..dab3272720e 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", "documentation": "https://docs.example.com/test-workflow", "source": "test-source"}, + FrontmatterLines: []string{"description: Test workflow", "documentation: 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.Documentation) 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..82835afd917 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.Documentation = data.Documentation 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..23f93664566 100644 --- a/pkg/workflow/compiler_yaml_test.go +++ b/pkg/workflow/compiler_yaml_test.go @@ -1702,6 +1702,42 @@ Test prompt. } } +func TestCompileWorkflowMetadataIncludesDocumentation(t *testing.T) { + tmpDir := testutil.TempDir(t, "lock-metadata-documentation") + workflowPath := filepath.Join(tmpDir, "documentation.md") + workflowContent := `--- +engine: copilot +documentation: 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.Documentation != "https://docs.example.com/automation/repository-health" { + t.Errorf("Documentation = %q, want documentation URL", metadata.Documentation) + } +} + 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..1104195c81f 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 "" } +// extractDocumentation extracts the documentation field from frontmatter. +func (c *Compiler) extractDocumentation(frontmatter map[string]any) string { + value, exists := frontmatter["documentation"] + if !exists { + return "" + } + + if strValue, ok := value.(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.go b/pkg/workflow/frontmatter_types.go index 214509bc5a0..4408c885651 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -343,9 +343,10 @@ type ObservabilityConfig struct { // This provides compile-time type safety and clearer error messages compared to map[string]any type FrontmatterConfig struct { // Core workflow fields - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Emoji string `json:"emoji,omitempty"` // Optional emoji to represent the workflow visually + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Documentation string `json:"documentation,omitempty"` + Emoji string `json:"emoji,omitempty"` // Optional emoji to represent the workflow visually // Engine accepts both a plain string engine name (e.g. "copilot") and an object-style // configuration (e.g. {id: copilot, max-continuations: 2}). Using any prevents // JSON unmarshal failures when the engine is an object, which would otherwise cause diff --git a/pkg/workflow/frontmatter_types_test.go b/pkg/workflow/frontmatter_types_test.go index 6f8a781f0a2..8cac30098d8 100644 --- a/pkg/workflow/frontmatter_types_test.go +++ b/pkg/workflow/frontmatter_types_test.go @@ -251,12 +251,13 @@ func TestParseFrontmatterConfig(t *testing.T) { t.Run("parses complete workflow config", func(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", + "name": "full-workflow", + "description": "A complete workflow", + "documentation": "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 +288,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.Documentation != "https://docs.example.com/full-workflow" { + t.Errorf("Documentation = %q, want documentation URL", config.Documentation) + } 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..741653aaebc 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"` + Documentation string `json:"documentation,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..d63c0ad31b2 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -38,6 +38,7 @@ func (c *Compiler) buildInitialWorkflowData( FrontmatterFieldLines: result.FieldLines, RawMarkdown: result.Markdown, Description: c.extractDescription(result.Frontmatter), + Documentation: c.extractDocumentation(result.Frontmatter), 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..3fbfeee797c 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 + Documentation 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-documentation.md b/schema-demos/schema-demo-documentation.md new file mode 100644 index 00000000000..b62636e7d53 --- /dev/null +++ b/schema-demos/schema-demo-documentation.md @@ -0,0 +1,30 @@ +--- +description: Demonstrates the `documentation` schema field +on: + workflow_dispatch: +permissions: + contents: read +engine: codex +documentation: https://docs.example.com/automation/repository-health +timeout-minutes: 5 +--- + +# Schema Demo: `documentation` + +This workflow was auto-generated to demonstrate usage of the `documentation` field in +the gh-aw frontmatter schema. It exists solely to achieve 100% schema feature +coverage. + +## What `documentation` Does + +Optional absolute HTTPS URL for human-facing workflow documentation. + +## Task + +Call `noop` -- this is a coverage-only demo workflow. + +**Important**: Always call the `noop` safe-output tool. + +```json +{"noop": {"message": "Coverage demo for `documentation` -- no action needed."}} +``` diff --git a/scripts/generate-schema-docs.js b/scripts/generate-schema-docs.js index 6e59fa18b65..b49df2fc6a9 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 === "documentation") return "https://docs.example.com/automation/repository-health"; return "example-value"; case "number": case "integer": From efa8d4f5dd7356ff98aabba83d44d27b9aa5d1c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:29:37 +0000 Subject: [PATCH 3/3] Address workflow docs metadata feedback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../minor-add-workflow-docs-metadata.md | 5 ++ .../docs/reference/frontmatter-full.md | 10 +-- .../src/content/docs/reference/frontmatter.md | 11 +-- pkg/parser/import_field_extractor.go | 9 +++ pkg/parser/import_processor.go | 1 + pkg/parser/schema_test.go | 32 ++++---- pkg/parser/schema_validation.go | 30 ++++++++ pkg/parser/schemas/main_workflow_schema.json | 18 +++-- .../compiler_orchestrator_workflow_test.go | 6 +- pkg/workflow/compiler_yaml_header.go | 2 +- pkg/workflow/compiler_yaml_test.go | 74 +++++++++++++++++-- .../frontmatter_extraction_metadata.go | 10 +-- pkg/workflow/frontmatter_types.go | 7 +- pkg/workflow/frontmatter_types_test.go | 20 ++--- pkg/workflow/lock_schema.go | 2 +- pkg/workflow/workflow_builder.go | 6 +- pkg/workflow/workflow_data.go | 2 +- schema-demos/schema-demo-documentation.md | 30 -------- schema-demos/schema-demo-metadata.md | 1 + scripts/generate-schema-docs.js | 2 +- 20 files changed, 180 insertions(+), 98 deletions(-) create mode 100644 .changeset/minor-add-workflow-docs-metadata.md delete mode 100644 schema-demos/schema-demo-documentation.md 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 fe824aa3481..e4a7762f652 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -28,11 +28,6 @@ name: "My Workflow" # (optional) description: "Description of the workflow" -# Optional absolute HTTPS URL for human-facing workflow documentation. Preserved -# in generated lock-file metadata without being fetched during compilation. -# (optional) -documentation: "https://docs.example.com/automation/repository-health" - # Optional emoji to represent the workflow visually in listings and UI surfaces. # (optional) emoji: "example-value" @@ -94,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 7555e144dc0..0e83c6ff13f 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -32,14 +32,6 @@ Provides a human-readable description of the workflow rendered as a comment in t description: "Workflow that analyzes pull requests and provides feedback" ``` -### Documentation Link (`documentation:`) - -Optional absolute HTTPS URL for human-facing workflow documentation. The compiler preserves the link in generated lock-file metadata without fetching it or changing workflow execution. - -```yaml wrap -documentation: "https://docs.example.com/automation/repository-health" -``` - ### Emoji (`emoji:`) An optional emoji to represent the workflow visually, for example in listings and UI surfaces. @@ -65,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 3649ecf21f7..b915d8ccece 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -46,31 +46,35 @@ func TestValidateMainWorkflowFrontmatter_RejectsUnsupportedTopLevelFields(t *tes } } -func TestValidateMainWorkflowFrontmatter_Documentation(t *testing.T) { +func TestValidateMainWorkflowFrontmatter_MetadataDocs(t *testing.T) { t.Parallel() for _, tt := range []struct { - name string - documentation any - wantErr bool + name string + docs any + wantErr bool }{ - {name: "absolute HTTPS URL", documentation: "https://docs.example.com/workflows/repo-health"}, - {name: "repository documentation URL", documentation: "https://github.com/OWNER/REPO/blob/main/docs/workflows/repository-health.md"}, - {name: "relative path", documentation: "docs/workflows/repo-health.md", wantErr: true}, - {name: "non-HTTPS URL", documentation: "http://docs.example.com/workflows/repo-health", wantErr: true}, - {name: "JavaScript URL", documentation: "javascript:alert(1)", wantErr: true}, - {name: "empty string", documentation: "", wantErr: true}, - {name: "object", documentation: map[string]any{"user": "https://docs.example.com/user-guide"}, wantErr: true}, + {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", - "documentation": tt.documentation, + "on": "workflow_dispatch", + "metadata": map[string]any{ + "docs": tt.docs, + }, }, "workflow.md") if (err != nil) != tt.wantErr { - t.Fatalf("documentation validation error = %v, wantErr %t", err, tt.wantErr) + t.Fatalf("metadata.docs validation error = %v, wantErr %t", err, tt.wantErr) } }) } 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 78205a12759..adb4e4a2350 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -20,14 +20,6 @@ "description": "Optional workflow description that is rendered as a comment in the generated GitHub Actions YAML file (.lock.yml)", "examples": ["Quickstart for using the GitHub Actions library"] }, - "documentation": { - "type": "string", - "minLength": 1, - "format": "uri", - "pattern": "^https://[^/?#\\s]+(?:[/?#][^\\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"] - }, "emoji": { "type": "string", "description": "Optional emoji to represent the workflow visually in listings and UI surfaces.", @@ -158,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 dab3272720e..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", "documentation": "https://docs.example.com/test-workflow", "source": "test-source"}, - FrontmatterLines: []string{"description: Test workflow", "documentation: https://docs.example.com/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,7 +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.Documentation) + 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 82835afd917..8fe3268545f 100644 --- a/pkg/workflow/compiler_yaml_header.go +++ b/pkg/workflow/compiler_yaml_header.go @@ -48,7 +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.Documentation = data.Documentation + 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 23f93664566..0a9aae68876 100644 --- a/pkg/workflow/compiler_yaml_test.go +++ b/pkg/workflow/compiler_yaml_test.go @@ -1702,12 +1702,13 @@ Test prompt. } } -func TestCompileWorkflowMetadataIncludesDocumentation(t *testing.T) { - tmpDir := testutil.TempDir(t, "lock-metadata-documentation") - workflowPath := filepath.Join(tmpDir, "documentation.md") +func TestCompileWorkflowMetadataIncludesDocs(t *testing.T) { + tmpDir := testutil.TempDir(t, "lock-metadata-docs") + workflowPath := filepath.Join(tmpDir, "docs.md") workflowContent := `--- engine: copilot -documentation: https://docs.example.com/automation/repository-health +metadata: + docs: https://docs.example.com/automation/repository-health on: issues --- # Test Workflow @@ -1733,8 +1734,69 @@ Test prompt. if metadata == nil { t.Fatal("Expected lock metadata") } - if metadata.Documentation != "https://docs.example.com/automation/repository-health" { - t.Errorf("Documentation = %q, want documentation URL", metadata.Documentation) + 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) + } + }) } } diff --git a/pkg/workflow/frontmatter_extraction_metadata.go b/pkg/workflow/frontmatter_extraction_metadata.go index 1104195c81f..5713bcbf222 100644 --- a/pkg/workflow/frontmatter_extraction_metadata.go +++ b/pkg/workflow/frontmatter_extraction_metadata.go @@ -55,14 +55,14 @@ func (c *Compiler) extractDescription(frontmatter map[string]any) string { return "" } -// extractDocumentation extracts the documentation field from frontmatter. -func (c *Compiler) extractDocumentation(frontmatter map[string]any) string { - value, exists := frontmatter["documentation"] - if !exists { +// 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 := value.(string); ok { + if strValue, ok := metadata["docs"].(string); ok { return strings.TrimSpace(strValue) } diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 4408c885651..214509bc5a0 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -343,10 +343,9 @@ type ObservabilityConfig struct { // This provides compile-time type safety and clearer error messages compared to map[string]any type FrontmatterConfig struct { // Core workflow fields - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Documentation string `json:"documentation,omitempty"` - Emoji string `json:"emoji,omitempty"` // Optional emoji to represent the workflow visually + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Emoji string `json:"emoji,omitempty"` // Optional emoji to represent the workflow visually // Engine accepts both a plain string engine name (e.g. "copilot") and an object-style // configuration (e.g. {id: copilot, max-continuations: 2}). Using any prevents // JSON unmarshal failures when the engine is an object, which would otherwise cause diff --git a/pkg/workflow/frontmatter_types_test.go b/pkg/workflow/frontmatter_types_test.go index 8cac30098d8..86493e79901 100644 --- a/pkg/workflow/frontmatter_types_test.go +++ b/pkg/workflow/frontmatter_types_test.go @@ -251,13 +251,15 @@ func TestParseFrontmatterConfig(t *testing.T) { t.Run("parses complete workflow config", func(t *testing.T) { frontmatter := map[string]any{ - "name": "full-workflow", - "description": "A complete workflow", - "documentation": "https://docs.example.com/full-workflow", - "engine": "copilot", - "source": "owner/repo/path@main", - "redirect": "owner/repo/new-path@main", - "tracker-id": "test-tracker-123", + "name": "full-workflow", + "description": "A complete workflow", + "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, @@ -288,8 +290,8 @@ func TestParseFrontmatterConfig(t *testing.T) { if config.Description != "A complete workflow" { t.Errorf("Description = %q, want %q", config.Description, "A complete workflow") } - if config.Documentation != "https://docs.example.com/full-workflow" { - t.Errorf("Documentation = %q, want documentation URL", config.Documentation) + 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" { diff --git a/pkg/workflow/lock_schema.go b/pkg/workflow/lock_schema.go index 741653aaebc..6fdf485d06e 100644 --- a/pkg/workflow/lock_schema.go +++ b/pkg/workflow/lock_schema.go @@ -38,7 +38,7 @@ type LockMetadata struct { BodyHash string `json:"body_hash,omitempty"` StopTime string `json:"stop_time,omitempty"` CompilerVersion string `json:"compiler_version,omitempty"` - Documentation string `json:"documentation,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 d63c0ad31b2..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,7 +42,7 @@ func (c *Compiler) buildInitialWorkflowData( FrontmatterFieldLines: result.FieldLines, RawMarkdown: result.Markdown, Description: c.extractDescription(result.Frontmatter), - Documentation: c.extractDocumentation(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 3fbfeee797c..42e626c008d 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -48,7 +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 - Documentation string // optional human-facing documentation URL preserved in lock metadata + 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-documentation.md b/schema-demos/schema-demo-documentation.md deleted file mode 100644 index b62636e7d53..00000000000 --- a/schema-demos/schema-demo-documentation.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -description: Demonstrates the `documentation` schema field -on: - workflow_dispatch: -permissions: - contents: read -engine: codex -documentation: https://docs.example.com/automation/repository-health -timeout-minutes: 5 ---- - -# Schema Demo: `documentation` - -This workflow was auto-generated to demonstrate usage of the `documentation` field in -the gh-aw frontmatter schema. It exists solely to achieve 100% schema feature -coverage. - -## What `documentation` Does - -Optional absolute HTTPS URL for human-facing workflow documentation. - -## Task - -Call `noop` -- this is a coverage-only demo workflow. - -**Important**: Always call the `noop` safe-output tool. - -```json -{"noop": {"message": "Coverage demo for `documentation` -- no action needed."}} -``` 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 b49df2fc6a9..d06992caee9 100755 --- a/scripts/generate-schema-docs.js +++ b/scripts/generate-schema-docs.js @@ -138,7 +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 === "documentation") return "https://docs.example.com/automation/repository-health"; + if (propName === "docs") return "https://docs.example.com/automation/repository-health"; return "example-value"; case "number": case "integer":