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
10 changes: 5 additions & 5 deletions actions/setup/js/runtime_import.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1943,8 +1943,8 @@ describe("runtime_import", () => {
it("should produce {{#if }} (falsy) for github.actor when context is unavailable", () => {
expect(wrapExpressionsInTemplateConditionals("{{#if github.actor}}body{{/if}}")).toBe("{{#if }}body{{/if}}");
});
it("should produce {{#if }} (falsy) for needs.activation.outputs.text when env var is absent", () => {
expect(wrapExpressionsInTemplateConditionals("{{#if needs.activation.outputs.text}}body{{/if}}")).toBe("{{#if }}body{{/if}}");
it("should produce {{#if }} (falsy) for steps.sanitized.outputs.text when env var is absent", () => {
expect(wrapExpressionsInTemplateConditionals("{{#if steps.sanitized.outputs.text}}body{{/if}}")).toBe("{{#if }}body{{/if}}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the deprecated-form JS test creates an undocumented silent failure path. The PR's claim that "compiled templates never contain the deprecated form at runtime" is correct for newly compiled workflows, but extractAndReplacePlaceholders does mechanical dot→underscore conversion with no knowledge of the Go-side transformation — so the old placeholder __GH_AW_NEEDS_ACTIVATION_OUTPUTS_TEXT__ can still be generated in edge cases, and that env var is never set.

💡 Details

If a workflow YAML reaches the JS runtime with the deprecated expression still present (cached artifact, test fixture, direct compiler bypass), extractAndReplacePlaceholders produces __GH_AW_NEEDS_ACTIVATION_OUTPUTS_TEXT__ — an env var that is never populated — causing silently empty output.

Consider either:

  1. Retaining the old test cases as comments that document the intentional behavior gap, or
  2. Adding a dedicated test asserting what happens when the deprecated form hits the JS layer, with a comment that the compiler is the single source of truth for preventing this.

});
it("should produce {{#if }} (falsy) for steps.foo.outputs.bar when env var is absent", () => {
expect(wrapExpressionsInTemplateConditionals("{{#if steps.foo.outputs.bar}}body{{/if}}")).toBe("{{#if }}body{{/if}}");
Expand Down Expand Up @@ -2045,9 +2045,9 @@ describe("runtime_import", () => {
const input = "{{#if ${{ github.event.issue.number }} }}body{{/if}}";
expect(extractAndReplacePlaceholders(input)).toBe("{{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }}body{{/if}}");
});
it("should convert needs.activation.outputs.text", () => {
const input = "{{#if ${{ needs.activation.outputs.text }} }}body{{/if}}";
expect(extractAndReplacePlaceholders(input)).toBe("{{#if __GH_AW_NEEDS_ACTIVATION_OUTPUTS_TEXT__ }}body{{/if}}");
it("should convert steps.sanitized.outputs.text", () => {
const input = "{{#if ${{ steps.sanitized.outputs.text }} }}body{{/if}}";
expect(extractAndReplacePlaceholders(input)).toBe("{{#if __GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT__ }}body{{/if}}");
});
it("should leave content without wrapped expressions unchanged", () => {
const input = "{{#if __GH_AW_GITHUB_ACTOR__ }}body{{/if}}";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR-47264: Extend detectTextOutputUsage to Recognise Deprecated needs.activation.outputs Forms

**Date**: 2026-07-22
**Status**: Draft
**Deciders**: Unknown (Copilot SWE Agent, pelikhan)

---

### Context

The workflow compiler automatically rewrites deprecated `${{ needs.activation.outputs.{text,title,body} }}` expressions to their modern `${{ steps.sanitized.outputs.{text,title,body} }}` equivalents. However, the `detectTextOutputUsage` function — which decides whether the sanitized step must be injected into the compiled workflow — only scanned for the modern form.

Workflows that had not yet been migrated to the modern syntax would silently compile without the sanitized step. This caused runtime failures particularly for `workflow_dispatch`-only triggers, where no content context is provided and the missing step caused undefined outputs.

The fix must not break any already-migrated workflows, and must preserve the existing deprecation-warning pathway so users are still notified to migrate.

### Decision

We will extend `detectTextOutputUsage` to also check for `${{ needs.activation.outputs.text }}`, `${{ needs.activation.outputs.title }}`, and `${{ needs.activation.outputs.body }}` alongside the already-supported modern forms, using short-circuit `if !hasXUsage` guards so that a single `strings.Contains` hit is enough to set the flag. The auto-rewrite that transforms deprecated to modern syntax at the expression level is left untouched.

### Alternatives Considered

#### Alternative 1: Reject deprecated syntax with a compile-time error

Return a compile error (or emit a fatal diagnostic) whenever a deprecated `needs.activation.outputs.*` expression is found, requiring authors to migrate before the workflow compiles.

Rejected because it is a breaking change for all existing unmigrated workflows. The project's stated intent is a gradual migration; forcing an immediate hard break contradicts that policy and would block legitimate workflows from compiling at all.

#### Alternative 2: Emit a lint warning and rely on authors to migrate before runtime

Log a prominent warning, leave `detectTextOutputUsage` as-is (only scanning modern forms), and document that deprecated syntax may produce incorrect compiled output until migrated.

Rejected because it preserves the silent-failure bug that caused the issue in the first place. Users would see a warning but would still receive a broken compiled workflow, making this behaviour invisible until a runtime failure occurred.

### Consequences

#### Positive
- Unmigrated workflows now compile correctly and include the sanitized step, eliminating the silent runtime failure.
- The fix is non-breaking: already-migrated workflows are unaffected because the `if !hasXUsage` guards skip the deprecated-form check once the modern form is found.
- Deprecation warnings (emitted to stderr by `ExpressionExtractor`) are preserved, continuing to guide authors toward the modern syntax.

#### Negative
- The deprecated `needs.activation.outputs.*` strings are now referenced in two places in the compiler: the auto-rewrite in `ExpressionExtractor` and the new detection guards in `detectTextOutputUsage`. This increases the surface area that must be updated when deprecated syntax support is eventually removed.
- Retaining dual-path detection extends the effective deprecation window, since unmigrated workflows no longer fail visibly and authors have less urgency to migrate.

#### Neutral
- Three new test cases were added to `TestDetectTextOutputUsage` and a new `TestExpressionExtractor_DeprecatedActivationOutputWarning` suite was introduced, increasing test coverage of the backwards-compatibility layer.
- Existing tests that used the deprecated form in `generateEnvVarName` and `NoCollisions` scenarios were updated to the modern form, reflecting that deprecated syntax is transformed before it reaches those code paths in production.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
18 changes: 16 additions & 2 deletions pkg/workflow/compiler_orchestrator_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,13 +428,27 @@ func (c *Compiler) tryParseFrontmatterConfig(frontmatter map[string]any) *Frontm
}

// detectTextOutputUsage checks if the markdown content uses ${{ steps.sanitized.outputs.text }},
// ${{ steps.sanitized.outputs.title }}, or ${{ steps.sanitized.outputs.body }}
// ${{ steps.sanitized.outputs.title }}, or ${{ steps.sanitized.outputs.body }}.
// It also recognises the deprecated ${{ needs.activation.outputs.{text,title,body} }} forms so
// that workflows that have not yet been migrated still compile correctly.
func (c *Compiler) detectTextOutputUsage(markdownContent string) bool {
// Check for any of the text-related output expressions
// Check for any of the text-related output expressions (modern form)
hasTextUsage := strings.Contains(markdownContent, "${{ steps.sanitized.outputs.text }}")
hasTitleUsage := strings.Contains(markdownContent, "${{ steps.sanitized.outputs.title }}")
hasBodyUsage := strings.Contains(markdownContent, "${{ steps.sanitized.outputs.body }}")

// Also recognise the deprecated needs.activation.outputs.* forms so that workflows
// using the old syntax still get the sanitized step included during compilation.
if !hasTextUsage {
hasTextUsage = strings.Contains(markdownContent, "${{ needs.activation.outputs.text }}")
}
if !hasTitleUsage {
hasTitleUsage = strings.Contains(markdownContent, "${{ needs.activation.outputs.title }}")
}
if !hasBodyUsage {
hasBodyUsage = strings.Contains(markdownContent, "${{ needs.activation.outputs.body }}")
}

hasUsage := hasTextUsage || hasTitleUsage || hasBodyUsage
detectionLog.Printf("Detected usage of sanitized outputs - text: %v, title: %v, body: %v, any: %v",
hasTextUsage, hasTitleUsage, hasBodyUsage, hasUsage)
Expand Down
17 changes: 17 additions & 0 deletions pkg/workflow/compute_text_lazy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,23 @@ func TestDetectTextOutputUsage(t *testing.T) {
content: "Body: \"${{ steps.sanitized.outputs.body }}\"",
expectedUsage: true,
},
// Deprecated needs.activation.outputs.* forms must also be detected so that
// workflows not yet migrated still compile correctly.
{
name: "with_deprecated_text_usage",
content: "Content: \"${{ needs.activation.outputs.text }}\"",
expectedUsage: true,
},
{
name: "with_deprecated_title_usage",
content: "Title: \"${{ needs.activation.outputs.title }}\"",
expectedUsage: true,
},
{
name: "with_deprecated_body_usage",
content: "Body: \"${{ needs.activation.outputs.body }}\"",
expectedUsage: true,
},
}

for _, tt := range tests {
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/expression_extraction_fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func FuzzRenderExpressions(f *testing.F) {
f.Add("Repo: ${{ github.repository }}, Actor: ${{ github.actor }}")
f.Add("${{ steps.a.outputs.x || inputs.y }}, ${{ inputs.z }}")

// Deprecated activation output syntax
// Deprecated activation output syntax (kept for backwards-compatibility transformer coverage)
f.Add("Content: ${{ needs.activation.outputs.text }}")
f.Add("Fallback: ${{ needs.activation.outputs.text || 'default' }}")

Expand Down Expand Up @@ -245,7 +245,7 @@ func FuzzExtractExpressions(f *testing.F) {
f.Add("Repo: ${{ github.repository }}, Actor: ${{ github.actor }}")
f.Add("${{ steps.a.outputs.x || inputs.y }}, ${{ inputs.z }}")

// Deprecated activation output syntax
// Deprecated activation output syntax (kept for backwards-compatibility transformer coverage)
f.Add("Content: ${{ needs.activation.outputs.text }}")
f.Add("Fallback: ${{ needs.activation.outputs.text || 'default' }}")

Expand Down
70 changes: 66 additions & 4 deletions pkg/workflow/expression_extraction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ func TestExpressionExtractor_GenerateEnvVarName(t *testing.T) {
wantName: "GH_AW_GITHUB_EVENT_ISSUE_NUMBER",
},
{
name: "needs output",
content: "needs.activation.outputs.text",
wantName: "GH_AW_NEEDS_ACTIVATION_OUTPUTS_TEXT",
name: "sanitized step outputs",
content: "steps.sanitized.outputs.text",
wantName: "GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT",
},
{
name: "complex expression with operators",
Expand Down Expand Up @@ -345,7 +345,7 @@ func TestExpressionExtractor_NoCollisions(t *testing.T) {
"github.actor",
"github.run_id",
"github.event.issue.number",
"needs.activation.outputs.text",
"steps.sanitized.outputs.text",
}

extractor := NewExpressionExtractor()
Expand Down Expand Up @@ -511,6 +511,68 @@ Other: ${{ needs.activation.outputs.comment_id }}
}
}

// TestExpressionExtractor_DeprecatedActivationOutputWarning verifies that extracting
// a deprecated needs.activation.outputs.* expression emits a deprecation warning to
// stderr while still producing the correct (transformed) mapping.
func TestExpressionExtractor_DeprecatedActivationOutputWarning(t *testing.T) {
tests := []struct {
name string
markdown string
deprecated string
modern string
}{
{
name: "text output emits warning",
markdown: "Content: ${{ needs.activation.outputs.text }}",
deprecated: "needs.activation.outputs.text",
modern: "steps.sanitized.outputs.text",
},
{
name: "title output emits warning",
markdown: "Title: ${{ needs.activation.outputs.title }}",
deprecated: "needs.activation.outputs.title",
modern: "steps.sanitized.outputs.title",
},
{
name: "body output emits warning",
markdown: "Body: ${{ needs.activation.outputs.body }}",
deprecated: "needs.activation.outputs.body",
modern: "steps.sanitized.outputs.body",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
extractor := NewExpressionExtractor()

stderr := captureStderr(func() {
mappings, err := extractor.ExtractExpressions(tt.markdown)
if err != nil {
t.Fatalf("ExtractExpressions() unexpected error: %v", err)
}
// Verify the mapping was produced with the modern expression.
found := false
for _, m := range mappings {
if m.Content == tt.modern {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing EnvVar assertion leaves deprecated-path env-var name generation untested. If the transformation maps needs.activation.outputs.text to the correct Content but produces the wrong EnvVar (e.g., still emitting GH_AW_NEEDS_ACTIVATION_OUTPUTS_TEXT instead of GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT), this test would still pass.

💡 Suggested fix

Add an EnvVar assertion inside the found-mapping check to close this gap:

for _, m := range mappings {
    if m.Content == tt.modern {
        found = true
        // Also verify the env-var name reflects the modern (transformed) form,
        // not the deprecated form. A buggy transformer could rewrite Content
        // correctly but leave the old env-var name, causing a runtime miss.
        wantEnvVar := "GH_AW_" + strings.ToUpper(
            strings.NewReplacer(".", "_").Replace(tt.modern))
        if m.EnvVar != wantEnvVar {
            t.Errorf("expected EnvVar %q, got %q", wantEnvVar, m.EnvVar)
        }
        break
    }
}

found = true
break
}
}
if !found {
t.Errorf("expected mapping for %q but not found", tt.modern)
}
})
Comment on lines +548 to +564

if !strings.Contains(stderr, tt.deprecated) {
t.Errorf("expected deprecation warning mentioning %q in stderr, got: %q", tt.deprecated, stderr)
}
if !strings.Contains(stderr, tt.modern) {
t.Errorf("expected deprecation warning mentioning %q in stderr, got: %q", tt.modern, stderr)
}
})
}
}

func TestApplyWorkflowDispatchFallbacks(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading