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
7 changes: 5 additions & 2 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,20 +480,23 @@ var IgnoredFrontmatterFields = []string{}
// - Workflow triggers: on (defines it as a main workflow)
// - Workflow execution: run-name, runs-on, if, timeout-minutes
// - Workflow metadata: name, tracker-id, strict
// - Workflow features: container, environment, features
// - Workflow features: container, environment
// - Access control: github-token
//
// The concurrency field is partially import-safe: shared workflows may contribute
// import-safe concurrency.group values and concurrency.job-discriminator values,
// but unsupported concurrency keys (for example cancel-in-progress) are rejected.
//
// The features field is partially import-safe: shared workflows may contribute
// the import-safe features.samples and features.intentional-failure flags, but
// other feature keys are rejected.
Comment on lines +490 to +492
//
// All other fields defined in main_workflow_schema.json can be used in shared workflows
// and will be properly imported and merged when the shared workflow is imported.
var SharedWorkflowForbiddenFields = []string{
"on", // Trigger field - only for main workflows
"container", // Container configuration
"environment", // Deployment environment
"features", // Feature flags
"github-token", // GitHub token configuration
"if", // Conditional execution
"name", // Workflow name
Expand Down
31 changes: 31 additions & 0 deletions pkg/parser/schema_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ func validateSharedWorkflowFields(frontmatter map[string]any) error {
}
continue
}
if key == "features" {
if err := validateSharedWorkflowFeaturesField(frontmatter["features"]); err != nil {
return err
}
continue
}
if setutil.Contains(sharedWorkflowForbiddenFields, key) {
forbiddenFound = append(forbiddenFound, key)
}
Expand Down Expand Up @@ -101,6 +107,31 @@ func validateSharedWorkflowConcurrencyField(concurrencyValue any) error {
}
}

// sharedWorkflowAllowedFeaturesFields lists the features.* sub-fields that are safe to
// import from shared workflows. Other feature keys are configuration/experimental
// settings intended only for main workflows and are rejected.
var sharedWorkflowAllowedFeaturesFields = map[string]struct{}{
"samples": {},
"intentional-failure": {},
}

// validateSharedWorkflowFeaturesField validates features: usage in shared workflows.
// Shared workflows may use features: only for import-safe feature flags.
func validateSharedWorkflowFeaturesField(featuresValue any) error {
featuresMap, ok := featuresValue.(map[string]any)
if !ok {
return errors.New("field 'features' cannot be used in shared workflows (only features.samples and features.intentional-failure are import-safe)")
}

for key := range featuresMap {
if _, ok := sharedWorkflowAllowedFeaturesFields[key]; !ok {
return fmt.Errorf("field 'features' in shared workflows can only include import-safe fields samples and intentional-failure; found unsupported key: %s", key)
}
}

return nil
}
Comment on lines +120 to +133

// validateSharedWorkflowOnField validates on: usage in shared workflows.
// Shared workflows may use on: only for import-safe activation fields.
func validateSharedWorkflowOnField(onValue any) error {
Expand Down
80 changes: 80 additions & 0 deletions pkg/workflow/features_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,86 @@ Top-level test-feature should override imported one.
t.Log("✓ Workflow compiled successfully with top-level features taking precedence")
}

// TestSharedWorkflowFeaturesSamplesAllowed verifies that features.samples is import-safe
// and can be declared in a shared workflow, including one nested under a "shared" subdirectory
// (which is subject to the same strict frontmatter validation as top-level workflow files).
func TestSharedWorkflowFeaturesSamplesAllowed(t *testing.T) {
tempDir := testutil.TempDir(t, "test-features-samples-*")
sharedDir := filepath.Join(tempDir, ".github", "workflows", "shared")
if err := os.MkdirAll(sharedDir, 0755); err != nil {
t.Fatalf("Failed to create shared directory: %v", err)
}
sharedPath := filepath.Join(sharedDir, "features.md")
sharedContent := `---
features:
samples: true
---

# Shared features configuration
`
if err := os.WriteFile(sharedPath, []byte(sharedContent), 0644); err != nil {
t.Fatalf("Failed to write shared features file: %v", err)
}

mainPath := filepath.Join(tempDir, ".github", "workflows", "main.md")
mainContent := `---
on: workflow_dispatch
imports:
- shared/features.md
---

# Main workflow
`
if err := os.WriteFile(mainPath, []byte(mainContent), 0644); err != nil {
t.Fatalf("Failed to write main workflow file: %v", err)
}

compiler := workflow.NewCompiler()
data, err := compiler.ParseWorkflowFile(mainPath)
if err != nil {
t.Fatalf("ParseWorkflowFile failed: %v", err)
}
if !data.UseSamples {
t.Fatal("Expected UseSamples to be true from imported features.samples: true")
}
}

// TestSharedWorkflowFeaturesValidationInSubdirectory verifies that unsupported features.*
// keys are still rejected for shared workflows nested under a "shared" subdirectory.
func TestSharedWorkflowFeaturesValidationInSubdirectory(t *testing.T) {
tempDir := testutil.TempDir(t, "test-features-invalid-*")
sharedDir := filepath.Join(tempDir, ".github", "workflows", "shared")
if err := os.MkdirAll(sharedDir, 0755); err != nil {
t.Fatalf("Failed to create shared directory: %v", err)
}
sharedPath := filepath.Join(sharedDir, "invalid.md")
sharedContent := `---
features:
test: true
---

# Unsupported shared feature
`
if err := os.WriteFile(sharedPath, []byte(sharedContent), 0644); err != nil {
t.Fatalf("Failed to write shared features file: %v", err)
}

mainPath := filepath.Join(tempDir, "main.md")
mainContent := "---\non: workflow_dispatch\nimports:\n - .github/workflows/shared/invalid.md\n---\n\n# Main workflow\n"
if err := os.WriteFile(mainPath, []byte(mainContent), 0644); err != nil {
t.Fatalf("Failed to write main workflow file: %v", err)
}

compiler := workflow.NewCompiler()
_, err := compiler.ParseWorkflowFile(mainPath)
if err == nil {
t.Fatal("Expected error for unsupported features key in shared workflow")
}
if !strings.Contains(err.Error(), "unsupported key: test") {
t.Fatalf("Expected error to mention unsupported key, got: %v", err)
}
}

// TestFeaturesMultipleImports verifies that features from multiple imports are merged correctly
func TestFeaturesMultipleImports(t *testing.T) {
// Create a temporary directory for test files
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/imported_engine_auth_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,5 @@ imports:
compiler := NewCompiler()
err := compiler.CompileWorkflow(mainFile)
require.Error(t, err)
assert.Contains(t, err.Error(), "mapping was used where sequence is expected")
assert.Contains(t, err.Error(), "Unknown properties: role, secret")
}
Loading