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: 6 additions & 1 deletion .github/aw/actions-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
"version": "v4.3.0",
"sha": "0057852bfaa89a56745cba8c7296529d2fc39830"
},
"actions/checkout@v3": {
"repo": "actions/checkout",
"version": "v3",
"sha": "f43a0e5ff2bd294095638e18286ca9a3d1956744"
},
"actions/checkout@v4": {
"repo": "actions/checkout",
"version": "v4",
Expand Down Expand Up @@ -100,7 +105,7 @@
"version": "v5.6.0",
"sha": "a26af69be951a213d495a4c3e4e4022e16d87065"
},
"actions/upload-artifact@v4": {
"actions/upload-artifact@v4.6.2": {
"repo": "actions/upload-artifact",
"version": "v4.6.2",
"sha": "ea165f8d65b6e75b540449e92b4886f43607fa02"
Expand Down
519 changes: 519 additions & 0 deletions .github/workflows/test-yaml-import.lock.yml

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions .github/workflows/test-yaml-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
name: Test YAML Import
on: issue_comment
imports:
- license-check.yml
engine: copilot
---

# Test YAML Import

This workflow imports the existing License Check workflow (license-check.yml) to demonstrate the YAML import feature.

The imported workflow contains a job (license-check) that will be merged with any jobs defined in this workflow.
60 changes: 60 additions & 0 deletions actions/setup/js/runtime_import.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,60 @@ function wrapExpressionsInTemplateConditionals(content) {
});
}

/**
* Extracts GitHub expressions from wrapped template conditionals and replaces them with placeholders
* Transforms {{#if ${{ expression }} }} to {{#if __GH_AW_PLACEHOLDER__ }}
* @param {string} content - The markdown content with wrapped expressions
* @returns {string} - Content with expressions replaced by placeholders
*/
function extractAndReplacePlaceholders(content) {
// Pattern to match {{#if ${{ expression }} }} where expression needs to be extracted
const pattern = /\{\{#if\s+\$\{\{\s*(.*?)\s*\}\}\s*\}\}/g;

return content.replace(pattern, (match, expr) => {
const trimmed = expr.trim();

// Generate placeholder name from expression
// Convert dots and special chars to underscores and uppercase
const placeholder = generatePlaceholderName(trimmed);

// Return the conditional with placeholder
return `{{#if __${placeholder}__ }}`;
});
}

/**
* Generates a placeholder name from a GitHub expression
* @param {string} expr - The GitHub expression (e.g., "github.event.issue.number")
* @returns {string} - The placeholder name (e.g., "GH_AW_GITHUB_EVENT_ISSUE_NUMBER")
*/
function generatePlaceholderName(expr) {
// Check if it's a simple property access chain (e.g., github.event.issue.number)
const simplePattern = /^[a-zA-Z][a-zA-Z0-9_.]*$/;

if (simplePattern.test(expr)) {
// Convert dots to underscores and uppercase
// e.g., "github.event.issue.number" -> "GH_AW_GITHUB_EVENT_ISSUE_NUMBER"
return "GH_AW_" + expr.replace(/\./g, "_").toUpperCase();
}

// For boolean literals, use special placeholders
if (expr === "true") {
return "GH_AW_TRUE";
}
if (expr === "false") {
return "GH_AW_FALSE";
}
if (expr === "null") {
return "GH_AW_NULL";
}

// For complex expressions or unknown variables, create a generic placeholder
// Replace non-alphanumeric characters with underscores
const sanitized = expr.replace(/[^a-zA-Z0-9_]/g, "_").toUpperCase();
return "GH_AW_" + sanitized;
}

/**
* Reads and processes a file or URL for runtime import
* @param {string} filepathOrUrl - The path to the file (relative to GITHUB_WORKSPACE) or URL to import
Expand Down Expand Up @@ -661,6 +715,10 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start
// This handles {{#if expression}} where expression is not already wrapped in ${{ }}
content = wrapExpressionsInTemplateConditionals(content);

// Extract and replace GitHub expressions in template conditionals with placeholders
// This transforms {{#if ${{ expression }} }} to {{#if __GH_AW_PLACEHOLDER__ }}
content = extractAndReplacePlaceholders(content);

// Process GitHub Actions expressions (validate and render safe ones)
if (hasGitHubActionsMacros(content)) {
content = processExpressions(content, `File ${filepath}`);
Expand Down Expand Up @@ -781,4 +839,6 @@ module.exports = {
evaluateExpression,
processExpressions,
wrapExpressionsInTemplateConditionals,
extractAndReplacePlaceholders,
generatePlaceholderName,
};
1 change: 1 addition & 0 deletions docs/src/content/docs/agent-factory-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn,
| [Terminal Stylist](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/terminal-stylist.md) | copilot | [![Terminal Stylist](https://github.com/githubnext/gh-aw/actions/workflows/terminal-stylist.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/terminal-stylist.lock.yml) | - | - |
| [Test Create PR Error Handling](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/test-create-pr-error-handling.md) | claude | [![Test Create PR Error Handling](https://github.com/githubnext/gh-aw/actions/workflows/test-create-pr-error-handling.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/test-create-pr-error-handling.lock.yml) | - | - |
| [Test Project URL Default](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/test-project-url-default.md) | copilot | [![Test Project URL Default](https://github.com/githubnext/gh-aw/actions/workflows/test-project-url-default.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/test-project-url-default.lock.yml) | - | - |
| [Test YAML Import](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/test-yaml-import.md) | copilot | [![Test YAML Import](https://github.com/githubnext/gh-aw/actions/workflows/test-yaml-import.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/test-yaml-import.lock.yml) | - | - |
| [The Daily Repository Chronicle](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/daily-repo-chronicle.md) | copilot | [![The Daily Repository Chronicle](https://github.com/githubnext/gh-aw/actions/workflows/daily-repo-chronicle.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/daily-repo-chronicle.lock.yml) | `0 16 * * 1-5` | - |
| [The Great Escapi](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/firewall-escape.md) | copilot | [![The Great Escapi](https://github.com/githubnext/gh-aw/actions/workflows/firewall-escape.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/firewall-escape.lock.yml) | - | - |
| [Tidy](https://github.com/githubnext/gh-aw/blob/main/.github/workflows/tidy.md) | copilot | [![Tidy](https://github.com/githubnext/gh-aw/actions/workflows/tidy.lock.yml/badge.svg)](https://github.com/githubnext/gh-aw/actions/workflows/tidy.lock.yml) | `0 7 * * *` | - |
Expand Down
55 changes: 55 additions & 0 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
"github.com/goccy/go-yaml"
)

var importLog = logger.New("parser:import_processor")
Expand All @@ -30,6 +31,7 @@ type ImportsResult struct {
MergedPostSteps string // Merged post-steps configuration from all imports (appended in order)
MergedLabels []string // Merged labels from all imports (union of label names)
MergedCaches []string // Merged cache configurations from all imports (appended in order)
MergedJobs string // Merged jobs from imported YAML workflows (JSON format)
ImportedFiles []string // List of imported file paths (for manifest)
AgentFile string // Path to custom agent file (if imported)
// ImportInputs uses map[string]any because input values can be different types (string, number, boolean).
Expand Down Expand Up @@ -177,6 +179,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
var labels []string // Track unique labels
labelsSet := make(map[string]bool) // Set for deduplicating labels
var caches []string // Track cache configurations (appended in order)
var jobsBuilder strings.Builder // Track jobs from imported YAML workflows
var agentFile string // Track custom agent file
importInputs := make(map[string]any) // Aggregated input values from all imports

Expand Down Expand Up @@ -212,6 +215,22 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
return nil, fmt.Errorf("failed to resolve import '%s': %w", filePath, err)
}

// Validate that .lock.yml files are not imported
if strings.HasSuffix(strings.ToLower(fullPath), ".lock.yml") {
if workflowFilePath != "" && yamlContent != "" {
line, column := findImportItemLocation(yamlContent, importPath)
importErr := &ImportError{
ImportPath: importPath,
FilePath: workflowFilePath,
Line: line,
Column: column,
Cause: fmt.Errorf("cannot import .lock.yml files. Lock files are compiled outputs from gh-aw. Import the source .md file instead"),
}
return nil, FormatImportError(importErr, yamlContent)
}
return nil, fmt.Errorf("cannot import .lock.yml files: '%s'. Lock files are compiled outputs from gh-aw. Import the source .md file instead", importPath)
}

// Check for duplicates before adding to queue
if !visited[fullPath] {
visited[fullPath] = true
Expand Down Expand Up @@ -282,6 +301,41 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
continue
}

// Check if this is a YAML workflow file (not .lock.yml)
if isYAMLWorkflowFile(item.fullPath) {
log.Printf("Detected YAML workflow file: %s", item.fullPath)

// Process YAML workflow import to extract jobs and services
jobsJSON, servicesJSON, err := processYAMLWorkflowImport(item.fullPath)
if err != nil {
return nil, fmt.Errorf("failed to process YAML workflow '%s': %w", item.importPath, err)
}

// Append jobs to merged jobs
if jobsJSON != "" && jobsJSON != "{}" {
jobsBuilder.WriteString(jobsJSON + "\n")
log.Printf("Added jobs from YAML workflow: %s", item.importPath)
}

// Append services to merged services (services from YAML are already in JSON format)
// Need to convert to YAML format for consistency with other services
if servicesJSON != "" && servicesJSON != "{}" {
// Convert JSON services to YAML format
var services map[string]any
if err := json.Unmarshal([]byte(servicesJSON), &services); err == nil {
servicesWrapper := map[string]any{"services": services}
servicesYAML, err := yaml.Marshal(servicesWrapper)
if err == nil {
servicesBuilder.WriteString(string(servicesYAML) + "\n")
log.Printf("Added services from YAML workflow: %s", item.importPath)
}
}
}

// YAML workflows don't have nested imports or markdown content, skip to next item
continue
}

// Read the imported file to extract nested imports
content, err := os.ReadFile(item.fullPath)
if err != nil {
Expand Down Expand Up @@ -510,6 +564,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a
MergedPostSteps: postStepsBuilder.String(),
MergedLabels: labels,
MergedCaches: caches,
MergedJobs: jobsBuilder.String(),
ImportedFiles: topologicalOrder,
AgentFile: agentFile,
ImportInputs: importInputs,
Expand Down
15 changes: 11 additions & 4 deletions pkg/parser/schema_deprecated_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,25 @@ func TestGetMainWorkflowDeprecatedFields(t *testing.T) {
t.Fatalf("GetMainWorkflowDeprecatedFields() error = %v", err)
}

// Check that timeout_minutes is NOT in the list (it was removed from schema completely)
// Users should use the timeout-minutes-migration codemod to migrate their workflows
// Check that timeout_minutes IS in the list as a deprecated field
// This allows strict mode to properly detect and reject it
found := false
var timeoutMinutesField *DeprecatedField
for _, field := range deprecatedFields {
if field.Name == "timeout_minutes" {
found = true
timeoutMinutesField = &field
break
}
}

if found {
t.Error("timeout_minutes should NOT be in the deprecated fields list (removed from schema)")
if !found {
t.Error("timeout_minutes should be in the deprecated fields list to support strict mode validation")
} else {
// Verify it has the correct replacement
if timeoutMinutesField.Replacement != "timeout-minutes" {
t.Errorf("timeout_minutes replacement = %v, want 'timeout-minutes'", timeoutMinutesField.Replacement)
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1791,6 +1791,12 @@
"description": "Workflow timeout in minutes (GitHub Actions standard field). Defaults to 20 minutes for agentic workflows. Has sensible defaults and can typically be omitted.",
"examples": [5, 10, 30]
},
"timeout_minutes": {
"type": "integer",
"deprecated": true,
"description": "DEPRECATED: Use 'timeout-minutes' instead. Workflow timeout in minutes.",
"x-deprecation-message": "Use 'timeout-minutes' (with hyphen) instead of 'timeout_minutes' (with underscore) to follow GitHub Actions naming conventions."
},
"concurrency": {
"description": "Concurrency control to limit concurrent workflow runs (GitHub Actions standard field). Supports two forms: simple string for basic group isolation, or object with cancel-in-progress option for advanced control. Agentic workflows enhance this with automatic per-engine concurrency policies (defaults to single job per engine across all workflows) and token-based rate limiting. Default behavior: workflows in the same group queue sequentially unless cancel-in-progress is true. See https://docs.github.com/en/actions/using-jobs/using-concurrency",
"oneOf": [
Expand Down
Loading
Loading