-
Notifications
You must be signed in to change notification settings - Fork 433
Recursively order nested with/env/secrets maps during YAML serialization
#40362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7b2034c
380b451
7a3d432
340766a
3f88dea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)}) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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" { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] 💡 SuggestionExtract 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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/zoom-out] When // 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Suggested fixAdd a targeted-field check inside the 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 💡 Suggested test skeletonAdd a case (or extend 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Suggested fixcase 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -346,6 +346,60 @@ func TestMarshalWithFieldOrder(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| func TestMarshalWithFieldOrder_OrdersNestedEnvWithSecretsRecursively(t *testing.T) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test exclusively uses 💡 Suggested fixAdd a test variant with 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 |
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 More robust alternativeUnmarshal 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] 💡 SuggestionThe indentation prefixes ( var parsed yaml.MapSlice
require.NoError(t, yaml.Unmarshal(yamlBytes, &parsed))
// walk parsed to find the "with" MapSlice and assert its keys are sortedNot 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() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd]
assertOrderis defined identically inyaml_test.go(line 384). Both files are in the sameworkflowpackage, so this can be a shared test helper — extract it to a file likeyaml_helpers_test.goto prevent the two definitions from drifting.💡 Example extraction