diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 3307bf9d003..edbd2e8785b 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -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" diff --git a/actions/setup/js/runtime_import.cjs b/actions/setup/js/runtime_import.cjs index 366a28bea5a..911735bf250 100644 --- a/actions/setup/js/runtime_import.cjs +++ b/actions/setup/js/runtime_import.cjs @@ -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 @@ -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}`); @@ -781,4 +839,6 @@ module.exports = { evaluateExpression, processExpressions, wrapExpressionsInTemplateConditionals, + extractAndReplacePlaceholders, + generatePlaceholderName, }; diff --git a/docs/src/content/docs/agent-factory-status.mdx b/docs/src/content/docs/agent-factory-status.mdx index f010167abf2..4ef6b6f2b59 100644 --- a/docs/src/content/docs/agent-factory-status.mdx +++ b/docs/src/content/docs/agent-factory-status.mdx @@ -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 * * *` | - | diff --git a/pkg/parser/schema_deprecated_test.go b/pkg/parser/schema_deprecated_test.go index e442144522c..9dbe15fdf80 100644 --- a/pkg/parser/schema_deprecated_test.go +++ b/pkg/parser/schema_deprecated_test.go @@ -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) + } } } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 601a4ce0ffe..45fedd88d15 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -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": [ diff --git a/pkg/workflow/action_pins_test.go b/pkg/workflow/action_pins_test.go index f41ea84b882..cd88cf3072e 100644 --- a/pkg/workflow/action_pins_test.go +++ b/pkg/workflow/action_pins_test.go @@ -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) diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index 3307bf9d003..edbd2e8785b 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -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" diff --git a/pkg/workflow/template_expression_integration_test.go b/pkg/workflow/template_expression_integration_test.go index 68e00e186bc..5c1a8aceeb4 100644 --- a/pkg/workflow/template_expression_integration_test.go +++ b/pkg/workflow/template_expression_integration_test.go @@ -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 }}") { @@ -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