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
38 changes: 38 additions & 0 deletions docs/adr/40362-order-nested-maps-during-yaml-serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# ADR-40362: Recursively Order Nested `with`/`env`/`secrets` Maps During YAML Serialization

**Date**: 2026-06-19
**Status**: Draft

## Context

The compiler serializes workflow data structures (`map[string]any`) to YAML, and reproducible output is required so that repeated compilations and wasm/frontmatter round-trips produce byte-identical results. Top-level step fields were already ordered deterministically, but nested `with:`, `env:`, and `secrets:` maps were emitted in Go's randomized map-iteration order. This caused the same input to serialize differently across runs, breaking determinism guarantees and round-trip stability tests. The ordering needed to apply recursively, since nested values can themselves contain maps (e.g. a `with` input whose value is another map).

## Decision

We will normalize nested map values inside the YAML serialization path rather than at each call site. `OrderMapFields` now routes every value through `prepareNestedMapValueForYAML`, which recursively converts `with`, `env`, and `secrets` sub-maps into key-sorted `yaml.MapSlice` values via `recursivelyOrderYAMLValue`. This centralizes deterministic ordering in one place, preserves existing top-level ordering behavior, and guarantees stable nested output (e.g. `alpha-*` before `zeta-*`) independent of Go map iteration.

## Alternatives Considered

### Alternative 1: Sort at each construction call site
Each place that builds a `with`/`env`/`secrets` map could emit an ordered structure directly. Rejected because it scatters ordering logic across many call sites, is easy to forget for new call sites, and does not handle arbitrarily nested values uniformly.

### Alternative 2: Thread an ordered map type through the whole pipeline
Replace `map[string]any` with `yaml.MapSlice` (or a custom ordered map) everywhere these structures are built and passed around. Rejected as too invasive for the scope of fixing determinism: it would touch large amounts of unrelated code and change many function signatures, with high regression risk.

## Consequences

### Positive
- Compiled YAML is deterministic for nested `with`/`env`/`secrets` blocks, fixing round-trip and repeated-compilation stability.
- Ordering logic is centralized in the serialization path, so new call sites benefit automatically.

### Negative
- Each marshal performs extra allocation and recursive copying of nested values.
- The special-cased field list (`with`, `env`, `secrets`) is hardcoded; adding another field that needs ordering requires editing `prepareNestedMapValueForYAML`.

### Neutral
- Non-string `yaml.MapSlice` keys are passed through without assuming string type, preserving correctness for already-ordered inputs.
- Behavior is exercised by a focused `yaml_test.go` case and an expanded wasm golden round-trip test.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27840723836) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
27 changes: 27 additions & 0 deletions pkg/workflow/wasm_golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,18 @@ on:
workflow_dispatch:
permissions:
contents: read
env:
ZETA_WORKFLOW: zeta
ALPHA_WORKFLOW: alpha
steps:
- name: Deterministic uses step
uses: actions/cache/restore@v4
with:
zeta-input: zeta
alpha-input: alpha
env:
ZETA_STEP: zeta
ALPHA_STEP: alpha
engine: copilot
timeout-minutes: 10
---
Expand Down Expand Up @@ -189,6 +201,21 @@ This workflow tests that compilation is deterministic.

require.Equal(t, normalizeHeredocDelimiters(results[0]), normalizeHeredocDelimiters(results[1]), "compilation 1 and 2 differ")
require.Equal(t, normalizeHeredocDelimiters(results[1]), normalizeHeredocDelimiters(results[2]), "compilation 2 and 3 differ")

assertOrder := func(before, after string) {

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.

[/tdd] assertOrder is defined identically in yaml_test.go (line 384). Both files are in the same workflow package, so this can be a shared test helper — extract it to a file like yaml_helpers_test.go to prevent the two definitions from drifting.

💡 Example extraction
// yaml_helpers_test.go
package workflow

import "testing"

func assertOrderIn(t *testing.T, s, before, after string) {
    t.Helper()
    bi := strings.Index(s, before)
    ai := strings.Index(s, after)
    if bi == -1 || ai == -1 {
        t.Fatalf("expected both %q and %q in output", before, after)
    }
    if bi >= ai {
        t.Fatalf("expected %q before %q", before, after)
    }
}

t.Helper()
beforeIndex := strings.Index(results[0], before)
afterIndex := strings.Index(results[0], after)
require.NotEqual(t, -1, beforeIndex, "expected %q in compiled YAML", before)
require.NotEqual(t, -1, afterIndex, "expected %q in compiled YAML", after)
if beforeIndex >= afterIndex {
t.Fatalf("expected %q before %q in compiled YAML:\n%s", before, after, results[0])
}
}

assertOrder(" ALPHA_WORKFLOW:", " ZETA_WORKFLOW:")
assertOrder(" alpha-input:", " zeta-input:")
assertOrder(" ALPHA_STEP:", " ZETA_STEP:")
}

// TestWasmGolden_NativeVsStringAPI compiles a workflow using both the native
Expand Down
78 changes: 76 additions & 2 deletions pkg/workflow/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ func OrderMapFields(data map[string]any, priorityFields []string) yaml.MapSlice
// This ensures important fields like "name", "on", "jobs" appear first
for _, fieldName := range priorityFields {
if value, exists := data[fieldName]; exists {
orderedData = append(orderedData, yaml.MapItem{Key: fieldName, Value: value})
orderedData = append(orderedData, yaml.MapItem{Key: fieldName, Value: prepareNestedMapValueForYAML(fieldName, value)})
}
}

Expand All @@ -334,12 +334,86 @@ func OrderMapFields(data map[string]any, priorityFields []string) yaml.MapSlice

// Phase 4: Add remaining fields to the ordered map
for _, key := range remainingKeys {
orderedData = append(orderedData, yaml.MapItem{Key: key, Value: data[key]})
orderedData = append(orderedData, yaml.MapItem{Key: key, Value: prepareNestedMapValueForYAML(key, data[key])})
}

return orderedData
}

func prepareNestedMapValueForYAML(fieldName string, value any) any {
switch v := value.(type) {
case map[string]any:
if fieldName == "with" || fieldName == "env" || fieldName == "secrets" {

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.

[/diagnose] "with", "env", and "secrets" are magic strings embedded in the condition. A new map-typed step field (or a misspelling during future maintenance) would silently skip ordering without any compiler or linter feedback.

💡 Suggestion

Extract to a package-level set so the policy is declared once and easy to grep:

var yamlMapFieldsToOrder = map[string]bool{
    "with":    true,
    "env":     true,
    "secrets": true,
}

// inside prepareNestedMapValueForYAML:
if yamlMapFieldsToOrder[fieldName] {
    return recursivelyOrderYAMLValue(v)
}

This also makes it trivial to extend when GitHub Actions adds new map-typed step fields.

return recursivelyOrderYAMLValue(v)
}
copied := make(map[string]any, len(v))

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.

[/zoom-out] When fieldName is not one of the three targeted fields, a map[string]any is returned unchanged in type — the yaml library will still serialize its keys in random order. This is intentional (the PR targets only with/env/secrets leaf maps), but without a comment here a future reader may wonder why only some intermediate maps get ordered. A one-liner would prevent the question:

// Not a targeted field: return as-is so existing ordering behaviour is preserved.
// We still recurse into child values to catch any nested "with"/"env"/"secrets".

for key, childValue := range v {
copied[key] = prepareNestedMapValueForYAML(key, childValue)
}
return copied
case map[string]string:
if fieldName == "with" || fieldName == "env" || fieldName == "secrets" {
return recursivelyOrderYAMLValue(v)
}
return value
case []any:
copied := make([]any, len(v))
for i, childValue := range v {
copied[i] = prepareNestedMapValueForYAML("", childValue)
}
return copied
case yaml.MapSlice:

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.

yaml.MapSlice-typed targeted fields bypass alphabetical sorting: when with, env, or secrets holds a yaml.MapSlice value (the type gopkg.in/yaml.v2 produces for maps when unmarshaling into interface{}), the type switch reaches case yaml.MapSlice: at line 360 before the map[string]any branch with the field-name check — recursivelyOrderYAMLValue is never called. Key order is silently preserved rather than sorted. Since the PR explicitly targets round-trip and wasm/frontmatter scenarios where yaml.v2 produces yaml.MapSlice, this is the most common input type in that path and the fix is a no-op for it.

💡 Suggested fix

Add a targeted-field check inside the yaml.MapSlice case:

case yaml.MapSlice:
    if fieldName == "with" || fieldName == "env" || fieldName == "secrets" {
        return recursivelyOrderYAMLValue(v)
    }
    copied := make(yaml.MapSlice, 0, len(v))
    for _, item := range v {
        key, ok := item.Key.(string)
        if !ok {
            copied = append(copied, yaml.MapItem{Key: item.Key, Value: prepareNestedMapValueForYAML("", item.Value)})
            continue
        }
        copied = append(copied, yaml.MapItem{Key: item.Key, Value: prepareNestedMapValueForYAML(key, item.Value)})
    }
    return copied

copied := make(yaml.MapSlice, 0, len(v))
for _, item := range v {
key, ok := item.Key.(string)
if !ok {

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.

[/tdd] The non-string key guard is a good defensive addition, but it has no test — a silent regression (e.g. a refactor that accidentally drops the !ok branch) would go undetected.

💡 Suggested test skeleton

Add a case (or extend TestMarshalWithFieldOrder_OrdersNestedEnvWithSecretsRecursively) that feeds a yaml.MapSlice with an integer key through MarshalWithFieldOrder and asserts it neither panics nor is dropped:

yamlSlice := yaml.MapSlice{
    {Key: 42, Value: "non-string-key"},
    {Key: "with", Value: map[string]any{"alpha": "a", "zeta": "z"}},
}
data := map[string]any{"step": yamlSlice}
// assert output contains "alpha" before "zeta" and still includes "non-string-key"

The PR description explicitly calls this out as a safety measure, so it warrants a regression test.

copied = append(copied, yaml.MapItem{Key: item.Key, Value: prepareNestedMapValueForYAML("", item.Value)})
continue
}
copied = append(copied, yaml.MapItem{Key: item.Key, Value: prepareNestedMapValueForYAML(key, item.Value)})
}
return copied
default:
return value
}
}

func recursivelyOrderYAMLValue(value any) any {
switch v := value.(type) {
case map[string]any:
orderedData := make(yaml.MapSlice, 0, len(v))
var keys []string
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
orderedData = append(orderedData, yaml.MapItem{Key: key, Value: recursivelyOrderYAMLValue(v[key])})
}
return orderedData
case map[string]string:
orderedData := make(yaml.MapSlice, 0, len(v))
for _, key := range sortedMapKeys(v) {
orderedData = append(orderedData, yaml.MapItem{Key: key, Value: v[key]})
}
return orderedData
case []any:
copied := make([]any, len(v))
for i, childValue := range v {
copied[i] = recursivelyOrderYAMLValue(childValue)
}
return copied
case yaml.MapSlice:

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.

recursivelyOrderYAMLValue copies yaml.MapSlice entries in existing order rather than sorting them: when this function encounters a yaml.MapSlice value (lines 401-406), it only recurses into child values — it never re-sorts the keys of the slice itself. This creates inconsistent behavior: a map[string]any with the same logical content gets alphabetically sorted (lines 378-388), but a yaml.MapSlice with the same content is left in whatever order it arrived. Any deeply-nested value under with/env/secrets that was previously unmarshaled as yaml.MapSlice will remain unsorted.

💡 Suggested fix
case yaml.MapSlice:
    sorted := make(yaml.MapSlice, 0, len(v))
    for _, item := range v {
        sorted = append(sorted, yaml.MapItem{
            Key:   item.Key,
            Value: recursivelyOrderYAMLValue(item.Value),
        })
    }
    sort.Slice(sorted, func(i, j int) bool {
        ki, oki := sorted[i].Key.(string)
        kj, okj := sorted[j].Key.(string)
        if oki && okj {
            return ki < kj
        }
        return false // preserve relative order for non-string keys
    })
    return sorted

copied := make(yaml.MapSlice, 0, len(v))
for _, item := range v {
copied = append(copied, yaml.MapItem{Key: item.Key, Value: recursivelyOrderYAMLValue(item.Value)})
}
return copied
default:
return value
}
}

// CleanYAMLNullValues removes " null" from YAML key-value pairs where the value is null.
//
// GitHub Actions YAML treats workflow_dispatch: and workflow_dispatch: null identically,
Expand Down
54 changes: 54 additions & 0 deletions pkg/workflow/yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,60 @@ func TestMarshalWithFieldOrder(t *testing.T) {
}
}

func TestMarshalWithFieldOrder_OrdersNestedEnvWithSecretsRecursively(t *testing.T) {

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.

Test exclusively uses map[string]any/map[string]string inputs — the round-trip yaml.MapSlice case is untested: all test data passed to MarshalWithFieldOrder uses Go map types that DO get sorted by recursivelyOrderYAMLValue. There is no test case where with, env, or secrets hold a yaml.MapSlice, which is the type produced by gopkg.in/yaml.v2 when unmarshaling maps into interface{}. The two bugs above would not be caught by this test suite — tests pass even when yaml.MapSlice-valued targeted fields are not sorted.

💡 Suggested fix

Add a test variant with yaml.MapSlice inputs to cover the round-trip path:

func TestMarshalWithFieldOrder_OrdersNestedEnvWithSecretsRecursively_MapSlice(t *testing.T) {
    data := map[string]any{
        "env": yaml.MapSlice{
            {Key: "ZETA_WORKFLOW", Value: "zeta"},
            {Key: "ALPHA_WORKFLOW", Value: "alpha"},
        },
        "with": yaml.MapSlice{
            {Key: "zeta-input", Value: "zeta"},
            {Key: "alpha-input", Value: "alpha"},
        },
    }
    // ... same assertOrder checks
}

This test would currently fail, exposing both yaml.go bugs above.

data := map[string]any{
"env": map[string]string{
"ZETA_WORKFLOW": "zeta",
"ALPHA_WORKFLOW": "alpha",
},
"secrets": map[string]string{
"ZETA_SECRET": "${{ secrets.ZETA_SECRET }}",
"ALPHA_SECRET": "${{ secrets.ALPHA_SECRET }}",
},
"steps": []any{
map[string]any{
"name": "Deterministic action",
"uses": "actions/checkout@v4",
"with": map[string]any{
"zeta-input": "zeta",
"alpha-input": map[string]any{
"zeta-child": "zeta",
"alpha-child": "alpha",
},
},
"env": map[string]string{
"ZETA_STEP": "zeta",
"ALPHA_STEP": "alpha",
},
},
},
}

yamlBytes, err := MarshalWithFieldOrder(data, []string{"env", "secrets", "steps"})
if err != nil {
t.Fatalf("MarshalWithFieldOrder() error = %v", err)
}

yamlStr := string(yamlBytes)
assertOrder := func(before, after string) {

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.

assertOrder uses fragile first-occurrence substring search across the entire YAML document: strings.Index(yamlStr, " ALPHA_WORKFLOW:") finds the first occurrence of that substring anywhere in the rendered YAML. If a key with the same name and indentation appeared in a different section (another env block, a comment, an error message embedded in the output), the assertion could pass or fail incorrectly. The same issue exists in wasm_golden_test.go at line 205.

💡 More robust alternative

Unmarshal the rendered YAML and walk the specific map keys in order:

var parsed yaml.MapSlice
require.NoError(t, yaml.Unmarshal(yamlBytes, &parsed))
// then extract the env block and check its key order explicitly
envSlice := findMapSliceKey(parsed, "env")
require.Equal(t, "ALPHA_WORKFLOW", envSlice[0].Key)
require.Equal(t, "ZETA_WORKFLOW", envSlice[1].Key)

This anchors the assertion to the actual env map structure rather than a position in the raw text.

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.

[/tdd] strings.Index finds the first occurrence of a substring. If the test data ever gains a second step (or a workflow-level key that happens to share a name with a step-level key), an assertion could silently pass or fail against the wrong scope.

💡 Suggestion

The indentation prefixes (" ALPHA_WORKFLOW:", " alpha-input:") do reduce false positives for the current data — but they couple the assertions to YAML indentation depth rather than structural position. A more robust alternative is to unmarshal the YAML output and compare key order structurally:

var parsed yaml.MapSlice
require.NoError(t, yaml.Unmarshal(yamlBytes, &parsed))
// walk parsed to find the "with" MapSlice and assert its keys are sorted

Not a blocker for this PR, but worth noting before the test pattern spreads further.

t.Helper()
beforeIndex := strings.Index(yamlStr, before)
afterIndex := strings.Index(yamlStr, after)
if beforeIndex == -1 || afterIndex == -1 {
t.Fatalf("expected both %q and %q in YAML:\n%s", before, after, yamlStr)
}
if beforeIndex >= afterIndex {
t.Fatalf("expected %q before %q in YAML:\n%s", before, after, yamlStr)
}
}

assertOrder(" ALPHA_WORKFLOW:", " ZETA_WORKFLOW:")
assertOrder(" ALPHA_SECRET:", " ZETA_SECRET:")
assertOrder(" alpha-input:", " zeta-input:")
assertOrder(" alpha-child:", " zeta-child:")
assertOrder(" ALPHA_STEP:", " ZETA_STEP:")
}

func TestExtractTopLevelYAMLSectionWithOrdering(t *testing.T) {
compiler := NewCompiler()

Expand Down
Loading