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
5 changes: 5 additions & 0 deletions .changeset/minor-add-workflow-docs-metadata.md

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

5 changes: 4 additions & 1 deletion docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:`)

Expand Down
9 changes: 9 additions & 0 deletions pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)...)
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions pkg/parser/schema_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"maps"
"net/url"
"strconv"
"strings"

"github.com/github/gh-aw/pkg/constants"
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions pkg/workflow/compiler_orchestrator_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_yaml_header.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
98 changes: 98 additions & 0 deletions pkg/workflow/compiler_yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
14 changes: 14 additions & 0 deletions pkg/workflow/frontmatter_extraction_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
14 changes: 10 additions & 4 deletions pkg/workflow/frontmatter_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/lock_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions pkg/workflow/workflow_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading