Skip to content

7.0 (fix): Resolve placeholders in list elements merged into the config - #16798

Open
codeconsole wants to merge 13 commits into
apache:7.0.xfrom
codeconsole:fix/config-list-placeholder-7.0.x
Open

codeconsole wants to merge 13 commits into
apache:7.0.xfrom
codeconsole:fix/config-list-placeholder-7.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

A placeholder in an element of a list is now resolved by grailsApplication.config:

app:
    origins:
        - https://app.example.com
        - '${EXTRA_ORIGIN:https://admin.example.com}'
    rows:
        - url: '${EXTRA_ORIGIN:https://admin.example.com}'
          db:
              host: '${DB_HOST:localhost}'
before now
config.getProperty('app.origins[1]') ${EXTRA_ORIGIN:…} https://admin.example.com
config.getProperty('app.origins', List) [https://app.example.com, ${EXTRA_ORIGIN:…}] [https://app.example.com, https://admin.example.com]
config.getProperty('app.rows[0].url') ${EXTRA_ORIGIN:…} https://admin.example.com
config.getProperty('app.rows[0].db.host') ${DB_HOST:localhost} localhost
config.navigate('app', 'rows')[0].db.host ${DB_HOST:localhost} localhost

The config's merge resolved placeholders only in string values. A list is stored as it is given, and a value under an indexed key, such as app.origins[1] or app.rows[0].url, is written into its list or map without that step, so any list from application.yml read through Grails' YAML loader, or from application.groovy, kept its placeholders, by index and in the whole list. A list from a property source read through Spring Boot's YAML loader was already resolved.

The merge now resolves the strings in a list, through the maps and lists it holds, and a value or map written under an indexed key:

  • A list or map with a placeholder is copied to resolve it, so the property source keeps the placeholder, and the config resolves it again when it is refreshed.
  • The merge writes one entry under several keys: a dotted key under the whole key and in its nested map, and an element of a list from application.yml in its list in the nested map, in its list under the flat key and under its indexed key. A merge resolves each text once, under the full name of its entry, such as app.hosts[0] or app.rows[0].url, and every write takes that value. Every read of an element agrees, by index, in the whole list, by navigation and in the export, even for ${random.uuid}.
  • The values of every other property source, such as system properties, command line arguments and Spring Boot's YAML loader, are merged as they are read, and the merge resolves them, as it does every other value. A placeholder escaped with a backslash, \${host}, is kept as text, as the Environment keeps it. On 7.0.x a string from such a source was resolved as it was read and again by the merge, so it read as the value of host.

The config now holds a copy of each list and map of a property source, an ArrayList or a LinkedList as its own kind, so a write by index from a property source of higher precedence no longer changes the source:

// application.groovy: app.origins = ['https://a.example.com', 'https://b.example.com']
// a property source of higher precedence: app.origins[1] = https://c.example.com
config.getProperty('app.origins', List)  // [https://a.example.com, https://c.example.com], as before
// the application.groovy source still holds b.example.com; on 7.0.x it held c.example.com

On 7.0.x the config held the lists of application.groovy as the source's own, so the write changed the source, and the element stayed changed after the higher source was removed. The values of other property sources were already copied as they were read. A list or map the source holds under several keys is copied once, so the config still holds one list under the flat key and in the nested map. A list merged with config.merge() is held as given, as before.

A list bound with @ConfigurationProperties, such as grails.cors.allowedOrigins, is not read through grailsApplication.config. Through Grails' YAML loader it still receives the placeholder as written (#16550; Grails 8 fixes it in #16552). The guide gives the workaround: a comma-separated value, which is resolved when it is bound.

Requested in #16732 (comment). The configuration guide's YAML page shows a placeholder in a list element read through grailsApplication.config, notes the @ConfigurationProperties case and its workaround, and says that an escaped placeholder is kept as text.

Tests

PropertySourcesConfigSpec:

  • Reads application.yml through Grails' YAML loader into a config and into a StandardEnvironment with ConfigurationPropertySources attached, and checks that the config answers as the environment does: a list of values by index and whole, and a list of maps with a map and a list inside an element.
  • A list held whole, as application.groovy gives it, of values, maps and lists, is resolved, and the property source keeps it as written.
  • A list without a placeholder is held as the same instance.
  • A list element merged in a nested map, as a dotted key and as an indexed key, reads the source once, and every read agrees.
  • A list element of application.yml, a value and a list element inside an element of a list of maps, and a key in brackets in a map inside an element, which the element holds as routes.x and as routes[x], each read the source once and read the same by index, in the whole list, by navigation and in the export.
  • An escaped placeholder from an ordinary property source, in a value, in a list and under an indexed key, is kept as text, as the environment keeps it, and a ConfigObject from it is resolved.
  • After the referenced value changes, refresh() resolves a list element again, for application.yml, application.groovy and an ordinary property source, and the source still holds the placeholder.
  • A write by index from a source of higher precedence leaves a list of application.yml, application.groovy or an ordinary property source as it is, and the config's copy is a list of the same kind. Building the config again without the higher source gives the source's list.

Every new feature fails on 7.0.x, except three rows that guard what 7.0.x already does:

  • a list without a placeholder is held as given guards that the merge does not copy a list it need not.
  • The ordinary-source row of the refresh() feature passes there too.
  • So does the application.yml row of the copy feature.

grails-core (532, 3 skipped) and grails-web-url-mappings (162) pass.

Outside the specs, nine element names were read from four sources: Grails' YAML loader, Spring Boot's YAML loader, a list held whole and a MapPropertySource. The shapes were values, maps in lists, a nested list, a map and a list inside an element, and an escaped placeholder. Each was read through getProperty, toProperties, navigate and getProperty(name, List), and compared with the Environment. No read returns a placeholder, or a value the environment does not. The reads that find nothing, such as an index into a list held whole or into a nested list, and navigation into a list of maps written by flat keys, find nothing on 7.0.x either. On 7.0.x, 20 of the 34 reads through Grails' YAML loader and 14 of the 34 of a list held whole returned the placeholder as written.

Merging up

  • With 7.0 (fix): Hold the list of an indexed key in the nested map that owns it #16771 (at 45969ff), the two branches merge without a conflict.

  • Merged with 7.0 (fix): Hold the list of an indexed key in the nested map that owns it #16771, the merge also needs one change. 7.0 (fix): Hold the list of an indexed key in the nested map that owns it #16771 writes the value below a subscript, db.host of app.rows[0].db.host, into its element through the merge hook again. It writes the value already resolved, under a name relative to the element. Without the change, an escaped placeholder in a map inside an element of a list from application.yml is resolved a second time: config['app.rows'][0].db.host and toProperties() give the value of host, where getProperty and navigate keep ${host}. The fix is to name the entry by the path of the map it is written to, and to record each resolved text as resolved for its entry:

    List<String> targetPath = targetMap.getPath();
    value = resolved(targetPath.isEmpty() ? sourceKey : String.join(".", targetPath) + '.' + sourceKey, sourceValue);
    String entry = entryName(name);
    String resolved = resolvedTexts.computeIfAbsent(Arrays.asList(entry, text), key -> resolvePlaceholders(text));
    resolvedTexts.putIfAbsent(Arrays.asList(entry, resolved), resolved);
    return resolved;

    With it, every read of that element agrees with the environment, and the config specs of grails-core and grails-bootstrap and GrailsCorsConfigurationSpec pass (569 tests). Add a feature that reads an escaped placeholder in a map inside an element of a YAML list through the whole list and the export.

  • On 8.0.x, binding a list with @ConfigurationProperties resolves its elements (Resolve placeholders in scalar configuration lists #16552), so drop the NOTE about it from the configuration guide's YAML page.

  • On 9.0.x, Treat a dotted configuration key past a value that is not a map as absent #16732 adds a sentence to the upgrade guide's section on configuration keys, and to the grails-9-upgrade skill, saying that config does not resolve a placeholder in a list element. Drop it when this reaches 9.0.x.

The merge hook resolved placeholders only in string values. A list is
stored as it is given, and a value under an indexed key, such as
app.origins[1] or app.rows[0].url, is written into its list or map
without the hook, so a placeholder in a list element from application.yml
or application.groovy was returned as written, by index and in the whole
list, where the Environment resolves it.

The hook now resolves the strings in a list, through the maps and lists
it holds, and a value or map under an indexed key. A list or map without
a placeholder is kept as given, and any other is copied, so the property
source keeps what it holds. A dotted key is written twice, under the whole
key and in the nested map, and both writes take one resolution.

@jamesfredley jamesfredley left a comment

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.

A placeholder in a list element is resolved. Not approving. An escaped placeholder in an ordinary property source is resolved a second time, and a YAML list placeholder can be stored as two different values.

The values of an ordinary property source are resolved as they are read,
and the merge resolved them again, so an escaped placeholder, \${host},
was read as the value of host. The merge now writes them as they are, as
it did for a list element and a value under an indexed key before this
change, and now for a string as well. A ConfigObject value is resolved
as it is read, as the other values are.

A list element of application.yml is written under several keys: in its
list in the nested map, in its list under the flat key and under its
indexed key, and the merge resolved it for each, so ${random.uuid} could
read differently by index and in the whole list. A merge now resolves a
text once under the full name of its entry, app.hosts[0] or
app.rows[0].url, and every write of that entry takes the one value. This
replaces the value kept for the second write of a dotted key, which held
only for a merge that parses flat keys.
@jdaugherty
jdaugherty requested review from jamesfredley and a balanced review from Copilot October 30, 2026 14:35

Copilot AI left a comment

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.

🟡 Changes recommended

The traversal resolves aliases of the same YAML leaf under different cache keys, evaluating the placeholder twice and allowing property access paths to return inconsistent UUIDs.

1 open issue
What changed in this PR

This PR extends Grails configuration merging to resolve placeholders inside lists and their nested maps, while preserving source values and avoiding repeated resolution of escaped placeholders.

Changes:

  • Recursively resolves list contents and caches resolved text per entry during a merge.
  • Merges already-resolved property-source values without resolving them again.
  • Adds regression tests and documents list placeholders and escaping.
File Description
grails-doc/​src/​en/​guide/​conf/​config/​ymlPropertyOptions.adoc Documents list placeholder resolution and escaping.
grails-core/​src/​test/​groovy/​org/​grails/​config/​PropertySourcesConfigSpec.groovy Tests nested lists, consistent reads, source preservation, and escaping.
grails-core/​src/​main/​groovy/​org/​grails/​config/​PropertySourcesConfig.java Evaluates ordinary source values before merging without further resolution.
grails-core/​src/​main/​groovy/​org/​grails/​config/​NavigableMapConfig.java Adds recursive resolution, per-merge caching, and an already-resolved merge path.

Review effort: Balanced

An element of a list from application.yml holds a key in brackets in a
map inside it, routes: {'[x]': ...}, under both of its names: x of the
map routes, and routes[x]. The merge resolved the text once for each
name, so with ${random.uuid} getProperty('app.rows[0].routes[x]') and
getProperty('app.rows', List)[0].routes.x read different values.

A key in brackets that is not an index and holds no dot now names its
resolution as the dotted part it is, so both names take one value. A
key with a dot stays in its brackets, so [a.b] is not taken for the
entry b of a map a.

@matrei matrei left a comment

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.

Thanks, this closes a long-standing gap, and the specs are thorough. I checked out the branch and confirmed that 7 of the new features fail on 7.0.x and pass here. I also tried a few extra shapes: an escaped placeholder in a YAML list and in a map inside a list element, refresh() after the referenced value changes, and a 20k-element list. All behaved as described.

Two things I'd like to sort out before approving, both inline:

  1. The new docs promise more than the change delivers. @ConfigurationProperties binding of a list from application.yml (Grails' YAML loader) still gets the raw placeholder, and that includes grails.cors.allowedOrigins.
  2. Are processAndEvaluate(), mergeResolved() and the resolvingPlaceholders flag still needed?

Similarly system arguments can be accessed.

A placeholder is resolved wherever it appears in a value, including in an element of a list or in a map inside one:

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.

This reads as if placeholders in list elements are now resolved everywhere, but the change only covers grailsApplication.config. With Grails' YAML loader, NavigableMapPropertySource also exposes the whole list under app.origins. Both Environment.getProperty('app.origins', List) and Boot's Binder return that list as it is, unresolved. The Environment resolves only the by-index read (app.origins[1]).

I checked with:

grails:
  cors:
    enabled: true
    allowedOrigins:
      - https://app.example.com
      - '${EXTRA_ORIGIN:https://admin.example.com}'
  • config.getProperty('grails.cors.allowedOrigins', List) gives [https://app.example.com, https://admin.example.com] with this PR.
  • GrailsCorsConfiguration bound through Binder.get(env).bind('grails.cors', ...) gives allowedOrigins == [https://app.example.com, ${EXTRA_ORIGIN:https://admin.example.com}], both on 7.0.x and with this PR.
  • With Spring Boot's own YAML loader, the Binder gives https://admin.example.com, because that loader only exposes the indexed keys.

So with this PR, config and @ConfigurationProperties beans disagree about the same list. That doesn't block the code here. But I'd limit the docs to grailsApplication.config, drop "as Spring's Environment does" for the whole-list read (also in the table in the PR description), and open a follow-up for the binder case. People reading this page will probably try it with grails.cors.allowedOrigins first. Something like:

Suggested change
A placeholder is resolved wherever it appears in a value, including in an element of a list or in a map inside one:
A placeholder in an element of a list, or in a map inside one, is resolved when the list is read through `grailsApplication.config`:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 9d12728, with your wording. The page now says a placeholder in a list element is resolved when the list is read through grailsApplication.config, and the sentence about the whole list no longer says "as Spring's Environment does". The table in the PR description no longer says it either.

- '${EXTRA_ORIGIN:https://admin.example.com}'
----

`grailsApplication.config` gives the resolved element whether the list is read whole, with `config.getProperty('app.origins', List)`, or by index, with `config.getProperty('app.origins[1]')`, as Spring's `Environment` does.

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.

Suggested change
`grailsApplication.config` gives the resolved element whether the list is read whole, with `config.getProperty('app.origins', List)`, or by index, with `config.getProperty('app.origins[1]')`, as Spring's `Environment` does.
`grailsApplication.config` gives the resolved element whether the list is read whole, with `config.getProperty('app.origins', List)`, or by index, with `config.getProperty('app.origins[1]')`.
NOTE: A list bound with `@ConfigurationProperties`, such as `grails.cors.allowedOrigins`, still receives the placeholder as written when `application.yml` is read through Grails' YAML loader.

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.

Correction to the docs comments: the binder case is already tracked as #16550. Grails 8 fixes it in #16552, with #16795 as a follow-up, by presenting lists only under their indexed names. That's listed as a breaking change in the 8.0 upgrade notes, so I don't expect it to come to 7.0.x, and there's nothing new to open.

Limiting the docs to grailsApplication.config still applies on 7.0.x. The NOTE, though, should be dropped when this is merged up to 8.0.x, where binding resolves list elements. Could you add that to "Merging up"? On 7.0.x the NOTE could also give the workaround from #16550:

Suggested change
`grailsApplication.config` gives the resolved element whether the list is read whole, with `config.getProperty('app.origins', List)`, or by index, with `config.getProperty('app.origins[1]')`, as Spring's `Environment` does.
NOTE: A list bound with `@ConfigurationProperties`, such as `grails.cors.allowedOrigins`, still receives the placeholder as written when `application.yml` is read through Grails' YAML loader. Write such a list as one comma-separated value, `'https://app.example.com,${EXTRA_ORIGIN:https://admin.example.com}'`, and the placeholder is resolved when the list is bound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken in 9d12728, with the workaround from #16550: the NOTE says that a list bound with @ConfigurationProperties still receives the placeholder as written through Grails' YAML loader, and that a comma-separated value is resolved when it is bound. "Merging up" in the description now says to drop the NOTE at 8.0.x, where binding resolves list elements.

Comment on lines +121 to +124
map.put(propertyName, processAndEvaluate(value));
}

configMap.merge(map, true);
mergeResolved(map);

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.

The merge hook now resolves strings, lists and maps under indexed keys, and it recurses into plain maps. Is processAndEvaluate() still needed here? I tried merging the raw values instead:

map.put(propertyName, value);
// ...
configMap.merge(map, true);

All config specs in grails-core still pass: 240 tests, including this PR's spec and the escaped-placeholder feature. That would let us drop mergeResolved() and the resolvingPlaceholders flag that is toggled around a merge. Ordinary sources would then get the same per-entry cache as the YAML path, so there would be one resolver instead of two.

The trade-off: processAndEvaluate() copies every list and map, so the config never holds an ordinary source's own list instance. Without it, a list with no placeholder from a MapPropertySource would be held as given. A later indexed write from a higher-priority source (app.list[1]) would then change it in place, which already happens today for lists from Grails' YAML loader. If that copy matters, I'd rather keep it explicit than get it as a side effect of a second resolver. I see the #16771 merge-up note relies on the flag, so let me know if there's a reason I'm missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No reason to keep them. c76316b removes processAndEvaluate(), mergeResolved() and the flag. The values of an ordinary source are merged as they are read, and the merge resolves them once per entry, as it does for application.yml. The escaped-placeholder feature passes unchanged.

The copy does matter, so it is now its own step, copyOf(), and it resolves nothing. Without it, a list of a MapPropertySource is held as given, so app.origins[1] from a source of higher precedence changes the source's own list. After that source is removed, a rebuilt config still reads the written element.

Checking this turned up the same change in place on 7.0.x for application.groovy, whose lists the config held as the source's own. I couldn't reproduce it for a list from Grails' YAML loader: in the same probe, the source's list stayed as written.

  • 17ff33c copies these sources too. Each list and map is copied once, so the config still holds one list under the flat key and in the nested map.
  • 628fb25 copies an ArrayList or a LinkedList as its own kind, as the config held it before.
  • a list from #description is held as a copy, so an indexed write from a source of higher precedence leaves the source as it is covers application.yml, application.groovy and an ordinary source. The application.groovy row fails at 3422036, and the ordinary row fails with the raw merge.

Without the flag, the #16771 merge-up needs a different change. #16771 writes db.host into the element app.rows[0] through the hook a second time, with the value already resolved, under a name relative to the element. This keeps it at one resolution:

  • name the entry by the target map's path, so app.rows[0] plus db.host;
  • record each result as resolved for its entry.
List<String> targetPath = targetMap.getPath();
value = resolved(targetPath.isEmpty() ? sourceKey : String.join(".", targetPath) + '.' + sourceKey, sourceValue);
String entry = entryName(name);
String resolved = resolvedTexts.computeIfAbsent(Arrays.asList(entry, text), key -> resolvePlaceholders(text));
resolvedTexts.putIfAbsent(Arrays.asList(entry, resolved), resolved);
return resolved;

I merged #16771 at 66a010e into this head and ran the config specs of grails-core and grails-bootstrap and GrailsCorsConfigurationSpec: with the change, all 524 tests pass. They include an escaped placeholder in a map inside an element of a YAML list, read by index, by navigation, as the whole list and in the export. Without the change, the whole list and the export give the value of host. "Merging up" in the description now has it.

protected void mergeMapEntry(NavigableMap rootMap, String path, NavigableMap targetMap, String sourceKey,
Object sourceValue, boolean parseFlatKeys, boolean isNestedSet) {
Object value = sourceValue;
if (resolvingPlaceholders && (sourceValue instanceof CharSequence || sourceValue instanceof List || sourceValue instanceof Map && isIndexed(sourceKey))) {

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.

Nit: a || b || c && d relies on operator precedence. Parentheses around sourceValue instanceof Map && isIndexed(sourceKey) would make it easier to read, and wrapping the line would help too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in c76316b: the Map check is in parentheses and the condition is wrapped. a7958ef puts the || at the end of the first line, where checkstyle wants it.

config.navigate('app', 'rows') == [[url: 'https://default.example.com', port: 8080]]
config.navigate('app', 'groups') == [['https://default.example.com']]

and: "the property source keeps the list as written"

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.

The description says the copy means "the property source keeps what it holds and resolves again when the config is refreshed", but no feature calls refresh(). I tried it: change the referenced value, call config.refresh(), and the list element follows while the source stays as written. Since that's the reason for the copy, could you add a feature for it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 6c7e7ba: a list element follows the value its placeholder names when the config is refreshed, and #description keeps the placeholder, for application.yml, application.groovy and an ordinary property source. It changes the value, calls refresh(), checks the whole list and navigation, and then checks that the source still holds http://${exampleHost}. It leaves out the read by index, which finds nothing in a list held whole, on 7.0.x too.


void 'a placeholder in a list element from application.yml is resolved as the environment resolves it'() {
given: "application.yml with a placeholder in a list of values and in a list of maps"
def yml = """\

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.

Nit: '''\ as in the later feature would avoid escaping \${...} here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 8109e45.

The values of an ordinary property source were resolved as they were
read, and the merge was told not to resolve them again. The merge now
resolves them, as it does the values of application.yml, so one
resolver serves every source, with the value resolved once per entry.

Reading the values had also copied each list and map, so the config
never held a source's own list, which an indexed write from a source of
higher precedence, app.origins[1], would change in place. That copy is
now made on its own, without resolving.
…efreshed

A list with a placeholder is copied to resolve it, so the property
source keeps the placeholder and the config resolves it again on
refresh(). The feature checks it for application.yml, application.groovy
and an ordinary property source.
A single-quoted string, as the later features use, holds the
placeholders as they are written in application.yml.
A list bound with @ConfigurationProperties from application.yml, read
through Grails' YAML loader, still receives the placeholder as written,
so the guide no longer says the whole list reads as the Environment
reads it. A note gives the comma-separated workaround.
A list of a property source read through Grails' loaders was held as the
source's own, so an indexed write from a source of higher precedence,
app.origins[1], changed the application.groovy source in place, and the
written element stayed there after that source was removed. These
sources are now copied too, each list and map once, so the config still
holds one list under the flat key and in the nested map.
The copy made every list an ArrayList, where the config held a list of
application.groovy or application.yml as it was, a LinkedList as a
LinkedList. An ArrayList or a LinkedList is now copied as its own kind,
and any other list, such as an unmodifiable one, as an ArrayList.
…aceholder-7.0.x

# Conflicts:
#	grails-core/src/test/groovy/org/grails/config/PropertySourcesConfigSpec.groovy
7.0.x now exports an entry written in brackets by its bracketed name
only (apache#16772), so app.rows[0].routes.x is no longer exported beside
app.rows[0].routes[x]. Every read of the entry still takes one value.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei: I merged 7.0.x, which now includes #16772. The head is now d64ca6d. PropertySourcesConfigSpec conflicted in its imports and in the features both branches added before CountingPropertySource; I kept both sides.

One test expectation changed with the merge. a placeholder in a list element of application.yml is resolved once, alike for every read checked that the export holds both app.rows[0].routes[x] and app.rows[0].routes.x. 7.0.x now exports that entry by its bracketed name only, so d64ca6d expects routes[x] alone. Every read of the entry still gives one value.

After the merge, grails-bootstrap (236 tests) and grails-web-url-mappings (162) pass, and so does grails-core apart from that one feature, which passes after the change. The "Merging up" section of the description is updated for #16771 at 45969ff. Merged with it, the specs pass (569 tests) with the change described there.

@testlens-app

testlens-app Bot commented Oct 31, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: d64ca6d
▶️ Tests: 57838 executed
⚪️ Checks: 57/57 completed


Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

5 participants