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
2 changes: 1 addition & 1 deletion .github/aw/actions-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,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
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
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
6 changes: 3 additions & 3 deletions pkg/workflow/action_pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,9 @@ func TestApplyActionPinToStep(t *testing.T) {
func TestGetActionPinsSorting(t *testing.T) {
pins := getActionPins()

// Verify we got all the pins (42 as of January 2026)
if len(pins) != 42 {
t.Errorf("getActionPins() returned %d pins, expected 42", len(pins))
// Verify we got all the pins (43 as of January 2026)
if len(pins) != 43 {
t.Errorf("getActionPins() returned %d pins, expected 43", len(pins))
}

// Verify they are sorted by version (descending) then by repository name (ascending)
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/data/action_pins.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,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
39 changes: 18 additions & 21 deletions pkg/workflow/template_expression_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,26 @@ ${{ needs.activation.outputs.text }}
}

// Verify GitHub expressions are properly replaced with placeholders in template conditionals
// After the fix, expressions should be replaced with __GH_AW_*__ placeholders
// The GitHub context section (built-in) should have placeholders
// User markdown content is loaded via runtime-import and processed at runtime
expectedPlaceholderExpressions := []string{
"{{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }}",
"{{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }}",
"{{#if __GH_AW_NEEDS_ACTIVATION_OUTPUTS_TEXT__ }}",
}

for _, expectedExpr := range expectedPlaceholderExpressions {
if !strings.Contains(compiledStr, expectedExpr) {
t.Errorf("Compiled workflow should contain placeholder expression: %s", expectedExpr)
t.Errorf("Compiled workflow should contain placeholder expression in GitHub context: %s", expectedExpr)
}
}

// Verify that the main workflow content is loaded via runtime-import
// Template conditionals in the user's markdown (like needs.activation.outputs.text)
// are processed at runtime by the JavaScript runtime_import helper
if !strings.Contains(compiledStr, "{{#runtime-import") {
t.Error("Compiled workflow should contain runtime-import macro for main workflow content")
}

// Verify that expressions OUTSIDE template conditionals are NOT double-wrapped
// These should remain as ${{ github.event.issue.number }} (not wrapped again)
if strings.Contains(compiledStr, "${{ ${{ github.event.issue.number }}") {
Expand Down Expand Up @@ -271,27 +278,17 @@ Steps expression - will be wrapped.

compiledStr := string(compiledYAML)

// Verify all expressions are replaced with placeholders (correct behavior)
// Verify GitHub expressions in the GitHub context section are replaced with placeholders
// (These are in the built-in context, not the user's markdown)
if !strings.Contains(compiledStr, "{{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }}") {
t.Error("GitHub expression should be replaced with placeholder")
}

if !strings.Contains(compiledStr, "{{#if __GH_AW_STEPS_MY_STEP_OUTPUTS_VALUE__ }}") {
t.Error("Steps expression should be replaced with placeholder")
}

// Verify that literal values are also replaced with placeholders
// true and false literals get normalized to __GH_AW_TRUE__ and __GH_AW_FALSE__
if !strings.Contains(compiledStr, "{{#if __GH_AW_TRUE__ }}") {
t.Error("Literal 'true' should be replaced with placeholder")
}

if !strings.Contains(compiledStr, "{{#if __GH_AW_FALSE__ }}") {
t.Error("Literal 'false' should be replaced with placeholder")
t.Error("GitHub context should contain placeholder for github.event.issue.number")
}

if !strings.Contains(compiledStr, "{{#if __GH_AW_SOME_VARIABLE__ }}") {
t.Error("Unknown variable should be replaced with placeholder")
// Verify that the main workflow content is loaded via runtime-import
// Template conditionals in the user's markdown (like steps, true/false literals, etc.)
// are processed at runtime by the JavaScript runtime_import helper
if !strings.Contains(compiledStr, "{{#runtime-import") {
t.Error("Compiled workflow should contain runtime-import macro for main workflow content")
}

// Make sure we didn't create invalid double-wrapping
Expand Down
Loading