From edbe6e67c21fa7a0c056aad57a9e0f421fc5c9df Mon Sep 17 00:00:00 2001 From: "Matteo Franci a.k.a. Fugerit" Date: Sat, 31 Oct 2026 10:25:54 +0100 Subject: [PATCH 1/3] fix: reflect-config.json class names --- .../freemarker/reflect-config.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/freemarker-core/src/main/resources/META-INF/native-image/org.freemarker/freemarker/reflect-config.json b/freemarker-core/src/main/resources/META-INF/native-image/org.freemarker/freemarker/reflect-config.json index 9cf36441f..5f1d9e69c 100644 --- a/freemarker-core/src/main/resources/META-INF/native-image/org.freemarker/freemarker/reflect-config.json +++ b/freemarker-core/src/main/resources/META-INF/native-image/org.freemarker/freemarker/reflect-config.json @@ -20,7 +20,7 @@ "condition" : { "typeReachable" : "freemarker.template.Configuration" }, - "name" : "freemarker.log._AvalonLoggerFactory.java", + "name" : "freemarker.log._AvalonLoggerFactory", "methods" : [ { "name" : "", "parameterTypes" : [ ] @@ -29,7 +29,7 @@ "condition" : { "typeReachable" : "freemarker.template.Configuration" }, - "name" : "freemarker.log._CommonsLoggingLoggerFactory.java", + "name" : "freemarker.log._CommonsLoggingLoggerFactory", "methods" : [ { "name" : "", "parameterTypes" : [ ] @@ -38,7 +38,7 @@ "condition" : { "typeReachable" : "freemarker.template.Configuration" }, - "name" : "freemarker.log._JULLoggerFactory.java", + "name" : "freemarker.log._JULLoggerFactory", "methods" : [ { "name" : "", "parameterTypes" : [ ] @@ -47,7 +47,7 @@ "condition" : { "typeReachable" : "freemarker.template.Configuration" }, - "name" : "freemarker.log._Log4jLoggerFactory.java", + "name" : "freemarker.log._Log4jLoggerFactory", "methods" : [ { "name" : "", "parameterTypes" : [ ] @@ -56,7 +56,7 @@ "condition" : { "typeReachable" : "freemarker.template.Configuration" }, - "name" : "freemarker.log._Log4jOverSLF4JTester.java", + "name" : "freemarker.log._Log4jOverSLF4JTester", "methods" : [ { "name" : "", "parameterTypes" : [ ] @@ -65,7 +65,7 @@ "condition" : { "typeReachable" : "freemarker.template.Configuration" }, - "name" : "freemarker.log._NullLoggerFactory.java", + "name" : "freemarker.log._NullLoggerFactory", "methods" : [ { "name" : "", "parameterTypes" : [ ] @@ -74,9 +74,9 @@ "condition" : { "typeReachable" : "freemarker.template.Configuration" }, - "name" : "freemarker.log.CommonsLoggingLoggerFactory.java", + "name" : "freemarker.log.CommonsLoggingLoggerFactory", "methods" : [ { "name" : "", "parameterTypes" : [ ] } ] -} ] \ No newline at end of file +} ] From 3edcabbab6535f6dd6fb16a362e51652db1bcdf8 Mon Sep 17 00:00:00 2001 From: "Matteo Franci a.k.a. Fugerit" Date: Sat, 31 Oct 2026 11:02:06 +0100 Subject: [PATCH 2/3] test(graalvm-native): expand native image test suite and run nativeTest in CI - Expand HelloFreeMarkerTest with JUnit 5 test scenarios covering core directives, built-ins, lambdas, dynamic evaluation (?eval, ?eval_json, ?interpret), Java records, enums, static models, overloaded methods, member access policies, template loaders, output formats, custom directives/methods, and Properties-based configuration - Add template resources (features-suite.ftlh, included-header.ftlh, macro-lib.ftlh) and data model members to test #include, #import, macros, and functions in native image - Run :freemarker-test-graalvm-native:nativeTest in GitHub Actions CI workflow and document it in README.md Note: Generative AI (Google Gemini) assistance was used only for the isolated freemarker-test-graalvm-native test project, reviewed and refined by the author, and not used on the FreeMarker library code itself. --- .github/workflows/ci.yml | 31 +- freemarker-test-graalvm-native/README.md | 8 +- .../build.gradle.kts | 1 + .../graalvm-native-config/reflect-config.json | 25 +- .../freemarker/core/graal/HelloDataModel.java | 60 ++ .../core/graal/HelloFreeMarker.java | 7 +- .../resources/templates/features-suite.ftlh | 33 + .../resources/templates/included-header.ftlh | 19 + .../main/resources/templates/macro-lib.ftlh | 28 + .../core/graal/HelloFreeMarkerTest.java | 692 +++++++++++++++++- 10 files changed, 872 insertions(+), 32 deletions(-) create mode 100644 freemarker-test-graalvm-native/src/main/resources/templates/features-suite.ftlh create mode 100644 freemarker-test-graalvm-native/src/main/resources/templates/included-header.ftlh create mode 100644 freemarker-test-graalvm-native/src/main/resources/templates/macro-lib.ftlh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59616de69..ccd757ce9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,31 +64,26 @@ jobs: with: java-version: 21 distribution: graalvm - # test pipeline to check native support + # Test pipeline to check GraalVM native image support: # - # - GraalVM is added to the runner - # - A simple native project is build and run - # - # At the something like that should be found in the log : - # - # INFO: name : FreeMarker Native Demo, version : 2.3.35-nightly - # Jan 15, 2025 4:28:19 PM freemarker.log._JULLoggerFactory$JULLogger info - # INFO: result : - # - # - # Hello : FreeMarker GraalVM Native Demo - # - # - #

Hello : FreeMarker GraalVM Native Demo

- #

Test template for Apache FreeMarker GraalVM native support (2.3.35-nightly)

- # - # + # - GraalVM 21 is added to the runner + # - The freemarker-test-graalvm-native executable is built (nativeCompile) and executed + # - The JUnit 5 native test suite is compiled and executed as a native image (nativeTest), + # exercising a broad percentage of the FreeMarker codebase across core directives, + # ranges/slicing, variadic/recursive macros and functions, built-ins, lambdas, dynamic + # evaluation (?eval, ?eval_json, ?interpret), BeansWrapper (Java records, enums, static + # models, overloaded methods, default/whitelist/blacklist/legacy member access policies), + # template loaders (#include, #import, auto_include, auto_import), TemplateConfiguration + # matchers, output formats, custom directives/methods, custom number/date formats, + # arithmetic engines, exception handlers, XML DOM NodeModel, Properties-based + # configuration, and logger resolution. - name: Test GraalVM native support (build and run) id: native_test if: runner.os != 'Windows' run: | ./gradlew :freemarker-test-graalvm-native:nativeCompile ./freemarker-test-graalvm-native/build/native/nativeCompile/freemarker-test-graalvm-native + ./gradlew :freemarker-test-graalvm-native:nativeTest - name: Upload Failed Report uses: actions/upload-artifact@v7 if: failure() && steps.build_step.outcome == 'failure' diff --git a/freemarker-test-graalvm-native/README.md b/freemarker-test-graalvm-native/README.md index d1e9ce984..d97e2d63f 100644 --- a/freemarker-test-graalvm-native/README.md +++ b/freemarker-test-graalvm-native/README.md @@ -1,4 +1,4 @@ -# freemarker-test-graalvm-natice +# freemarker-test-graalvm-native Test project for GraalVM support. This is not built by default. @@ -39,6 +39,12 @@ Test project for GraalVM support. This is not built by default. ``` +4. Run the JUnit test suite inside a GraalVM native image: + + ```shell + ./gradlew :freemarker-test-graalvm-native:nativeTest + ``` + ## CI (GitHub workflow) GraalVM native test for this module is included in the GitHub [CI](../.github/workflows/ci.yml) workflow. diff --git a/freemarker-test-graalvm-native/build.gradle.kts b/freemarker-test-graalvm-native/build.gradle.kts index 0e80fdef9..a705c8a23 100644 --- a/freemarker-test-graalvm-native/build.gradle.kts +++ b/freemarker-test-graalvm-native/build.gradle.kts @@ -70,6 +70,7 @@ graalvmNative { binaries.all { fallback.set(false) verbose.set(true) + buildArgs.add("--add-exports=java.xml/com.sun.org.apache.xml.internal.utils=ALL-UNNAMED") configurationFileDirectories.from(file("src/main/graalvm-native-config")) resources.autodetect() } diff --git a/freemarker-test-graalvm-native/src/main/graalvm-native-config/reflect-config.json b/freemarker-test-graalvm-native/src/main/graalvm-native-config/reflect-config.json index 80342dff6..f67498f95 100644 --- a/freemarker-test-graalvm-native/src/main/graalvm-native-config/reflect-config.json +++ b/freemarker-test-graalvm-native/src/main/graalvm-native-config/reflect-config.json @@ -1,6 +1,27 @@ [ { "name": "org.freemarker.core.graal.HelloDataModel", - "allPublicMethods": true + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "org.freemarker.core.graal.HelloDataModel$Item", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "org.freemarker.core.graal.HelloDataModel$Status", + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true } -] \ No newline at end of file +] diff --git a/freemarker-test-graalvm-native/src/main/java/org/freemarker/core/graal/HelloDataModel.java b/freemarker-test-graalvm-native/src/main/java/org/freemarker/core/graal/HelloDataModel.java index 3052f0e04..69db8414f 100644 --- a/freemarker-test-graalvm-native/src/main/java/org/freemarker/core/graal/HelloDataModel.java +++ b/freemarker-test-graalvm-native/src/main/java/org/freemarker/core/graal/HelloDataModel.java @@ -19,12 +19,34 @@ package org.freemarker.core.graal; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import freemarker.ext.beans.TemplateAccessible; + public class HelloDataModel { + public enum Status { + ACTIVE, + PENDING, + ARCHIVED + } + + public record Item(String title, BigDecimal price, boolean inStock, List tags) { + } + + public String exposedNote = "public-field-ok"; + private String name; private String version; + private Status status = Status.ACTIVE; + + private List items = new ArrayList<>(); + public String getVersion() { return version; } @@ -33,6 +55,7 @@ public void setVersion(String version) { this.version = version; } + @TemplateAccessible public String getName() { return name; } @@ -41,4 +64,41 @@ public void setName(String name) { this.name = name; } + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public List getItems() { + return Collections.unmodifiableList(items); + } + + public void setItems(List items) { + this.items = new ArrayList<>(items); + } + + public String formatItem(String label, int quantity) { + return label + " x" + quantity; + } + + public String formatItem(String label, String note) { + return label + " (" + note + ")"; + } + + @TemplateAccessible + public String whitelistedAction() { + return "allowed:" + name; + } + + public String getRestrictedSecret() { + return "top-secret"; + } + + public static String staticGreeting(String target) { + return "Hello from static, " + target + "!"; + } + } diff --git a/freemarker-test-graalvm-native/src/main/java/org/freemarker/core/graal/HelloFreeMarker.java b/freemarker-test-graalvm-native/src/main/java/org/freemarker/core/graal/HelloFreeMarker.java index 1d151a024..749d193e6 100644 --- a/freemarker-test-graalvm-native/src/main/java/org/freemarker/core/graal/HelloFreeMarker.java +++ b/freemarker-test-graalvm-native/src/main/java/org/freemarker/core/graal/HelloFreeMarker.java @@ -31,6 +31,11 @@ public class HelloFreeMarker { private final static Logger log = Logger.getLogger(HelloFreeMarker.class.getName()); public static void main(String[] args) throws Exception { + String output = executeHelloWorld(); + log.info(String.format("result :\n%s", output)); + } + + public static String executeHelloWorld() throws Exception { // To test native configuration Class.forName("freemarker.ext.jython.JythonModel"); @@ -48,7 +53,7 @@ public static void main(String[] args) throws Exception { // Process template Template template = cfg.getTemplate("hello-world.ftlh"); template.process(dataModel, buffer); - log.info(String.format("result :\n%s", buffer)); + return buffer.toString(); } } diff --git a/freemarker-test-graalvm-native/src/main/resources/templates/features-suite.ftlh b/freemarker-test-graalvm-native/src/main/resources/templates/features-suite.ftlh new file mode 100644 index 000000000..249b4fa98 --- /dev/null +++ b/freemarker-test-graalvm-native/src/main/resources/templates/features-suite.ftlh @@ -0,0 +1,33 @@ +<#-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> +<#import "macro-lib.ftlh" as lib> +<#include "included-header.ftlh"> +<@lib.badge text=name kind="primary"; upperKind>(${upperKind}) +<#list items> +
    +<#items as item> +
  • ${item?counter}.${item?item_parity}:${item.title}=${item.price?string("0.00")}[${item.tags?join(",")}]
  • + +
+<#else> +

Empty

+ +TotalInStock=${lib.sumPrices(items)?string("0.00")} +OverloadInt=${formatItem("Box", 3)} +OverloadStr=${formatItem("Box", "fragile")} diff --git a/freemarker-test-graalvm-native/src/main/resources/templates/included-header.ftlh b/freemarker-test-graalvm-native/src/main/resources/templates/included-header.ftlh new file mode 100644 index 000000000..092aafb76 --- /dev/null +++ b/freemarker-test-graalvm-native/src/main/resources/templates/included-header.ftlh @@ -0,0 +1,19 @@ +<#-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> +
[${name} :: ${status}]
diff --git a/freemarker-test-graalvm-native/src/main/resources/templates/macro-lib.ftlh b/freemarker-test-graalvm-native/src/main/resources/templates/macro-lib.ftlh new file mode 100644 index 000000000..2c2d5e913 --- /dev/null +++ b/freemarker-test-graalvm-native/src/main/resources/templates/macro-lib.ftlh @@ -0,0 +1,28 @@ +<#-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> +<#macro badge text kind="info">${text}<#nested kind?upper_case> +<#function sumPrices items> + <#local total = 0> + <#list items as item> + <#if item.inStock> + <#local total = total + item.price> + + + <#return total> + diff --git a/freemarker-test-graalvm-native/src/test/java/org/freemarker/core/graal/HelloFreeMarkerTest.java b/freemarker-test-graalvm-native/src/test/java/org/freemarker/core/graal/HelloFreeMarkerTest.java index e20256006..249745b6d 100644 --- a/freemarker-test-graalvm-native/src/test/java/org/freemarker/core/graal/HelloFreeMarkerTest.java +++ b/freemarker-test-graalvm-native/src/test/java/org/freemarker/core/graal/HelloFreeMarkerTest.java @@ -19,24 +19,696 @@ package org.freemarker.core.graal; -import freemarker.log.Logger; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.io.Writer; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.TimeZone; + +import javax.xml.parsers.DocumentBuilderFactory; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.xml.sax.InputSource; + +import freemarker.cache.ClassTemplateLoader; +import freemarker.cache.ConditionalTemplateConfigurationFactory; +import freemarker.cache.FileExtensionMatcher; +import freemarker.cache.FirstMatchTemplateConfigurationFactory; +import freemarker.cache.MultiTemplateLoader; +import freemarker.cache.OrMatcher; +import freemarker.cache.PathGlobMatcher; +import freemarker.cache.StringTemplateLoader; +import freemarker.cache.TemplateLoader; +import freemarker.core.ArithmeticEngine; +import freemarker.core.CSSOutputFormat; +import freemarker.core.Environment; +import freemarker.core.HTMLOutputFormat; +import freemarker.core.JSONOutputFormat; +import freemarker.core.JavaScriptOutputFormat; +import freemarker.core.PlainTextOutputFormat; +import freemarker.core.RTFOutputFormat; +import freemarker.core.TemplateConfiguration; +import freemarker.core.TemplateDateFormat; +import freemarker.core.TemplateDateFormatFactory; +import freemarker.core.TemplateFormatUtil; +import freemarker.core.TemplateNumberFormat; +import freemarker.core.TemplateNumberFormatFactory; +import freemarker.core.TemplateValueFormatException; +import freemarker.core.XHTMLOutputFormat; +import freemarker.core.XMLOutputFormat; +import freemarker.ext.beans.BeansWrapper; +import freemarker.ext.beans.BeansWrapperBuilder; +import freemarker.ext.beans.BlacklistMemberAccessPolicy; +import freemarker.ext.beans.MemberSelectorListMemberAccessPolicy.MemberSelector; +import freemarker.ext.beans.WhitelistMemberAccessPolicy; +import freemarker.ext.dom.NodeModel; +import freemarker.log.Logger; +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapper; +import freemarker.template.DefaultObjectWrapperBuilder; +import freemarker.template.SimpleNumber; +import freemarker.template.SimpleScalar; +import freemarker.template.Template; +import freemarker.template.TemplateDateModel; +import freemarker.template.TemplateDirectiveBody; +import freemarker.template.TemplateDirectiveModel; +import freemarker.template.TemplateException; +import freemarker.template.TemplateExceptionHandler; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.TemplateNumberModel; +import freemarker.template.TemplateScalarModel; class HelloFreeMarkerTest { - private final static Logger log = Logger.getLogger(HelloFreeMarker.class.getName()); + private static Configuration newConfiguration() { + Configuration cfg = new Configuration(Configuration.VERSION_2_3_34); + cfg.setLocale(Locale.US); + cfg.setTimeZone(TimeZone.getTimeZone("UTC")); + cfg.setDefaultEncoding("UTF-8"); + cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + return cfg; + } + + private static String render(Configuration cfg, String templateSource, Object dataModel) throws Exception { + Template template = new Template("inline", new StringReader(templateSource), cfg); + try (StringWriter out = new StringWriter()) { + template.process(dataModel, out); + return out.toString(); + } + } + + @Test + void testMain() throws Exception { + HelloFreeMarker.main(new String[0]); + String output = HelloFreeMarker.executeHelloWorld(); + Assertions.assertTrue(output.contains("Hello : FreeMarker GraalVM Native Demo")); + Assertions.assertTrue(output.contains("

Hello : FreeMarker GraalVM Native Demo

")); + Assertions.assertTrue(output.contains( + "Test template for Apache FreeMarker GraalVM native support (" + Configuration.getVersion() + ")")); + } + + @Test + void testFeaturesSuiteTemplate() throws Exception { + Configuration cfg = newConfiguration(); + cfg.setClassForTemplateLoading(HelloDataModel.class, "/templates"); + + HelloDataModel model = new HelloDataModel(); + model.setName("NativeSuite"); + model.setVersion(Configuration.getVersion().toString()); + model.setStatus(HelloDataModel.Status.ACTIVE); + model.setItems(Arrays.asList( + new HelloDataModel.Item("Keyboard", new BigDecimal("49.50"), true, Arrays.asList("hw", "usb")), + new HelloDataModel.Item("Monitor", new BigDecimal("199.99"), false, Collections.singletonList("display")), + new HelloDataModel.Item("Mouse", new BigDecimal("25.25"), true, Arrays.asList("hw", "wireless")) + )); + + Template template = cfg.getTemplate("features-suite.ftlh"); + String output; + try (StringWriter writer = new StringWriter()) { + template.process(model, writer); + output = writer.toString(); + } + + Assertions.assertTrue(output.contains("
[NativeSuite :: ACTIVE]
")); + Assertions.assertTrue(output.contains("NativeSuite(PRIMARY)")); + Assertions.assertTrue(output.contains("
  • 1.odd:Keyboard=49.50[hw,usb]
  • ")); + Assertions.assertTrue(output.contains("
  • 2.even:Monitor=199.99[display]
  • ")); + Assertions.assertTrue(output.contains("
  • 3.odd:Mouse=25.25[hw,wireless]
  • ")); + Assertions.assertTrue(output.contains("TotalInStock=74.75")); + Assertions.assertTrue(output.contains("OverloadInt=Box x3")); + Assertions.assertTrue(output.contains("OverloadStr=Box (fragile)")); + } + + @Test + void testCoreDirectivesAndFlowControl() throws Exception { + Configuration cfg = newConfiguration(); + Map model = new LinkedHashMap<>(); + model.put("score", 85); + model.put("role", "editor"); + model.put("words", Arrays.asList("alpha", "beta", "gamma")); + model.put("emptyList", Collections.emptyList()); + + String templateSource = "" + + "<#if (score >= 90)>A<#elseif (score >= 80)>B<#else>C|" + + "<#switch role><#case 'admin'>ADM<#break><#case 'editor'>EDT<#break><#default>USR|" + + "<#list words>[<#items as w>${w?index}:${w?is_first?c}:${w?is_last?c}:${w}<#sep>,]<#else>NONE|" + + "<#list emptyList>[<#items as w>${w}]<#else>EMPTY|" + + "<#macro wrap prefix='(' suffix=')'><#list 1..2 as i>${prefix}<#nested i>${suffix}" + + "<@wrap prefix='[' suffix=']'; idx>#${idx}|" + + "<#function doubleVal n><#return n * 2>${doubleVal(21)}|" + + "<#assign captured><#compress> hello world ${captured}|" + + "<#attempt>ok<#recover>err:" + + "<#attempt>${missingVar}<#recover>recovered:${.error?has_content?c}|" + + "<#noparse>${notEvaluated}|" + + "${missingVar!'defaultVal'}:${missingVar???c}:${score???c}"; + + String actual = render(cfg, templateSource, model); + Assertions.assertEquals( + "B|EDT|[0:true:false:alpha,1:false:false:beta,2:false:true:gamma]|EMPTY|[#1][#2]|42|hello world|ok:recovered:true|${notEvaluated}|defaultVal:false:true", + actual); + } + + @Test + void testRangesSlicesConcatenationAndKeyValIteration() throws Exception { + Configuration cfg = newConfiguration(); + Map model = new LinkedHashMap<>(); + model.put("letters", Arrays.asList("a", "b", "c", "d", "e")); + model.put("word", "FreeMarker"); + Map map1 = new LinkedHashMap<>(); + map1.put("x", 1); + map1.put("y", 2); + Map map2 = new LinkedHashMap<>(); + map2.put("y", 20); + map2.put("z", 30); + model.put("m1", map1); + model.put("m2", map2); + + String templateSource = "" + + "inc=${(1..4)?join(',')}|" + + "dec=${(4..1)?join(',')}|" + + "excl=${(1..<4)?join(',')}|" + + "len=${(10..*3)?join(',')}|" + + "slice=${letters[1..3]?join('')}:${letters[3..]?join('')}:${word[0..<4]}|" + + "seqConcat=${(['p'] + ['q', 'r'])?join('-')}|" + + "hashConcat=<#assign m = m1 + m2><#list m as k, v>${k}=${v}<#sep>,|" + + "loopCtrl=<#list 1..6 as n><#if n == 2><#continue><#if n == 5><#break>${n}"; + + String actual = render(cfg, templateSource, model); + Assertions.assertEquals( + "inc=1,2,3,4|dec=4,3,2,1|excl=1,2,3|len=10,11,12|slice=bcd:de:Free|" + + "seqConcat=p-q-r|hashConcat=x=1,y=20,z=30|loopCtrl=134", + actual); + } + + @Test + void testAdvancedMacrosVarargsAndRecursion() throws Exception { + Configuration cfg = newConfiguration(); + String templateSource = "" + + "<#macro tag name attrs...>" + + "<${name}<#list attrs?keys?sort as k> ${k}=\"${attrs[k]}\"><#nested name?upper_case>" + + "" + + "<@tag name='div' id='main' role='banner'; upper>${upper}|" + + "<#function sumAll first rest...>" + + "<#local total = first>" + + "<#list rest as r><#local total = total + r>" + + "<#return total>" + + "" + + "${sumAll(10, 20, 30, 40)}|" + + "<#function factorial n>" + + "<#if (n <= 1)><#return 1><#else><#return n * factorial(n - 1)>" + + "" + + "fact5=${factorial(5)}|" + + "<#macro greet who>Hi ${who}!" + + "<#assign targetMacro = 'greet'>" + + "<@.vars[targetMacro] who='Native'/>"; + + String actual = render(cfg, templateSource, Collections.emptyMap()); + Assertions.assertEquals( + "
    DIV
    |100|fact5=120|Hi Native!", + actual); + } + + @Test + void testStringNumberBooleanAndDateBuiltIns() throws Exception { + Configuration cfg = newConfiguration(); + Map model = new LinkedHashMap<>(); + model.put("text", " hello freeMarker world "); + model.put("path", "/api/v1/items?name=a&b"); + model.put("num", new BigDecimal("-42.678")); + model.put("idx", 3); + model.put("flag", true); + model.put("epoch", Date.from(Instant.parse("2025-01-15T16:28:19Z"))); + model.put("sqlTs", Timestamp.from(Instant.parse("2025-01-15T16:28:19Z"))); + + String templateSource = "" + + "${text?trim?cap_first}|" + + "${text?trim?capitalize}|" + + "${text?trim?upper_case}|" + + "${text?trim?replace('freeMarker', 'Native')}|" + + "${text?trim?keep_before(' ')}:${text?trim?keep_after(' ')}|" + + "${'7'?left_pad(3, '0')}:${'7'?right_pad(3, '_')}|" + + "${'prefix'?ensure_starts_with('/')}:" + + "${text?contains('freeMarker')?c}:${text?trim?starts_with('hello')?c}:${text?trim?ends_with('world')?c}|" + + "${'a\"b'?json_string}|" + + "${'a b'?url('UTF-8')}|" + + "${num?abs?c}:${num?round?c}:${num?floor?c}:${num?ceiling?c}:${num?int?c}:${num?string('0.00')}|" + + "${idx?lower_abc}:${idx?upper_abc}|" + + "${flag?c}:${flag?string('YES', 'NO')}:${flag?then('T', 'F')}|" + + "${epoch?datetime?string('yyyy-MM-dd HH:mm:ss')}|" + + "${sqlTs?iso_utc}"; + + String actual = render(cfg, templateSource, model); + Assertions.assertEquals( + "Hello freeMarker world|Hello Freemarker World|HELLO FREEMARKER WORLD|hello Native world|" + + "hello:freeMarker world|007:7__|/prefix:true:true:true|a\\\"b|a%20b|" + + "42.678:-43:-43:-42:-42:-42.68|c:C|true:YES:T|2025-01-15 16:28:19|2025-01-15T16:28:19Z", + actual); + } + + @Test + void testSequenceHashLambdaAndDynamicEvalBuiltIns() throws Exception { + Configuration cfg = newConfiguration(); + Map model = new LinkedHashMap<>(); + model.put("nums", Arrays.asList(5, 2, 8, 1, 9, 3)); + Map scores = new LinkedHashMap<>(); + scores.put("alice", 10); + scores.put("bob", 20); + model.put("scores", scores); + model.put("dynamicTpl", "Sum=${scores.alice + scores.bob}"); + + String templateSource = "" + + "size=${nums?size},first=${nums?first},last=${nums?last}|" + + "sorted=${nums?sort?join(',')}|" + + "rev=${nums?sort?reverse?join(',')}|" + + "contains8=${nums?seq_contains(8)?c},idx8=${nums?seq_index_of(8)}|" + + "filtered=${nums?filter(x -> x >= 5)?sort?join(',')}|" + + "mapped=${nums?filter(x -> x <= 3)?sort?map(x -> x * 10)?join(',')}|" + + "takeWhile=${nums?take_while(x -> x < 8)?join(',')}|" + + "dropWhile=${nums?drop_while(x -> x < 8)?join(',')}|" + + "chunks=<#list nums?chunk(4) as c>[${c?join(',')}]|" + + "keys=${scores?keys?join(',')},vals=${scores?values?join(',')}|" + + "eval=${'1 + 2 * 3'?eval}|" + + "json=${'{\"k\": [10, 20]}'?eval_json.k?join('+')}|" + + "interpret=<@dynamicTpl?interpret/>"; + + String actual = render(cfg, templateSource, model); + Assertions.assertEquals( + "size=6,first=5,last=3|sorted=1,2,3,5,8,9|rev=9,8,5,3,2,1|contains8=true,idx8=2|" + + "filtered=5,8,9|mapped=10,20,30|takeWhile=5,2|dropWhile=8,1,9,3|chunks=[5,2,8,1][9,3]|" + + "keys=alice,bob,vals=10,20|eval=7|json=10+20|interpret=Sum=30", + actual); + } + + @Test + void testBeansWrapperRecordsEnumsStaticsFieldsAndMemberAccessPolicies() throws Exception { + Configuration cfg = newConfiguration(); + + DefaultObjectWrapperBuilder owb = new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34); + owb.setExposeFields(true); + DefaultObjectWrapper ow = owb.build(); + cfg.setObjectWrapper(ow); + + HelloDataModel bean = new HelloDataModel(); + bean.setName("BeanTest"); + bean.setStatus(HelloDataModel.Status.PENDING); + bean.setItems(Collections.singletonList( + new HelloDataModel.Item("Book", new BigDecimal("12.00"), true, Collections.singletonList("edu")) + )); + + Map root = new LinkedHashMap<>(); + root.put("bean", bean); + root.put("enums", ow.getEnumModels()); + root.put("statics", ow.getStaticModels()); + + String templateSource = "" + + "field=${bean.exposedNote}|" + + "record=${bean.items[0].title()}:${bean.items[0].price?c}|" + + "enum=${(bean.status == enums['org.freemarker.core.graal.HelloDataModel$Status'].PENDING)?c}|" + + "static=${statics['org.freemarker.core.graal.HelloDataModel'].staticGreeting('GraalVM')}|" + + "overload=${bean.formatItem('Item', 2)}:${bean.formatItem('Item', 'ok')}"; + + String actual = render(cfg, templateSource, root); + Assertions.assertEquals( + "field=public-field-ok|record=Book:12|enum=true|static=Hello from static, GraalVM!|overload=Item x2:Item (ok)", + actual); + + // Also test LegacyDefaultMemberAccessPolicy (which loads unsafeMethods.properties) + Configuration legacyCfg = new Configuration(Configuration.VERSION_2_3_29); + BeansWrapper legacyWrapper = new BeansWrapperBuilder(Configuration.VERSION_2_3_29).build(); + legacyCfg.setObjectWrapper(legacyWrapper); + Assertions.assertEquals("BeanTest", render(legacyCfg, "${name}", bean)); + } + + @Test + void testWhitelistAndBlacklistMemberAccessPolicies() throws Exception { + HelloDataModel bean = new HelloDataModel(); + bean.setName("PolicyUser"); + + // 1. WhitelistMemberAccessPolicy: allows @TemplateAccessible methods (getName, whitelistedAction), + // blocks unannotated getRestrictedSecret() + DefaultObjectWrapperBuilder whitelistOwb = new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34); + whitelistOwb.setMemberAccessPolicy(new WhitelistMemberAccessPolicy(Collections.emptyList())); + Configuration whitelistCfg = newConfiguration(); + whitelistCfg.setObjectWrapper(whitelistOwb.build()); + + String whitelistOut = render( + whitelistCfg, + "${name}|${whitelistedAction()}|${restrictedSecret!'BLOCKED'}", + bean); + Assertions.assertEquals("PolicyUser|allowed:PolicyUser|BLOCKED", whitelistOut); + + // 2. BlacklistMemberAccessPolicy: explicitly blacklist getRestrictedSecret() + MemberSelector blockedSelector = MemberSelector.parse( + HelloDataModel.class.getName() + ".getRestrictedSecret()", + HelloDataModel.class.getClassLoader()); + DefaultObjectWrapperBuilder blacklistOwb = new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_34); + blacklistOwb.setMemberAccessPolicy(new BlacklistMemberAccessPolicy(Collections.singletonList(blockedSelector))); + Configuration blacklistCfg = newConfiguration(); + blacklistCfg.setObjectWrapper(blacklistOwb.build()); + + String blacklistOut = render( + blacklistCfg, + "${name}|${formatItem('Bag', 1)}|${restrictedSecret!'DENIED'}", + bean); + Assertions.assertEquals("PolicyUser|Bag x1|DENIED", blacklistOut); + } + + @Test + void testMultiTemplateLoaderAndOutputFormats() throws Exception { + Configuration cfg = newConfiguration(); + StringTemplateLoader stringLoader = new StringTemplateLoader(); + stringLoader.putTemplate("dynamic.ftlh", "

    ${unsafe}&

    <#noautoesc>${raw}|${unsafe?no_esc}"); + stringLoader.putTemplate("xml-doc.ftlx", ""); + stringLoader.putTemplate( + "mixed-formats.ftl", + "<#ftl output_format='HTML'>${unsafe}|<#outputformat 'XML'>${unsafe}"); + + ClassTemplateLoader classLoader = new ClassTemplateLoader(HelloDataModel.class, "/templates"); + cfg.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[] { stringLoader, classLoader })); + cfg.setRecognizeStandardFileExtensions(true); + + Map model = new LinkedHashMap<>(); + model.put("name", "LoaderTest"); + model.put("status", HelloDataModel.Status.ARCHIVED); + model.put("unsafe", ""); + model.put("raw", "bold"); + + try (StringWriter htmlOut = new StringWriter()) { + Template htmlTpl = cfg.getTemplate("dynamic.ftlh"); + Assertions.assertSame(HTMLOutputFormat.INSTANCE, htmlTpl.getOutputFormat()); + htmlTpl.process(model, htmlOut); + Assertions.assertEquals( + "

    <script>&"'</script>&

    bold|", + htmlOut.toString()); + } + + try (StringWriter xmlOut = new StringWriter()) { + Template xmlTpl = cfg.getTemplate("xml-doc.ftlx"); + Assertions.assertSame(XMLOutputFormat.INSTANCE, xmlTpl.getOutputFormat()); + xmlTpl.process(model, xmlOut); + Assertions.assertEquals("", xmlOut.toString()); + } + + try (StringWriter mixedOut = new StringWriter()) { + cfg.getTemplate("mixed-formats.ftl").process(model, mixedOut); + Assertions.assertEquals( + "<script>&"'</script>|<script>&"'</script>", + mixedOut.toString()); + } + + try (StringWriter headerOut = new StringWriter()) { + cfg.getTemplate("included-header.ftlh").process(model, headerOut); + Assertions.assertEquals("
    [LoaderTest :: ARCHIVED]
    \n", headerOut.toString().replace("\r\n", "\n")); + } + + Assertions.assertEquals("JSON", JSONOutputFormat.INSTANCE.getName()); + Assertions.assertEquals("XHTML", XHTMLOutputFormat.INSTANCE.getName()); + Assertions.assertEquals("RTF", RTFOutputFormat.INSTANCE.getName()); + Assertions.assertEquals("JavaScript", JavaScriptOutputFormat.INSTANCE.getName()); + Assertions.assertEquals("CSS", CSSOutputFormat.INSTANCE.getName()); + Assertions.assertEquals("plainText", PlainTextOutputFormat.INSTANCE.getName()); + } + + @Test + void testCustomDirectivesAndMethods() throws Exception { + Configuration cfg = newConfiguration(); + + TemplateMethodModelEx repeatMethod = new TemplateMethodModelEx() { + @Override + public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException { + String text = ((TemplateScalarModel) arguments.get(0)).getAsString(); + int count = ((TemplateNumberModel) arguments.get(1)).getAsNumber().intValue(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + if (i > 0) { + sb.append('-'); + } + sb.append(text); + } + return new SimpleScalar(sb.toString()); + } + }; + + TemplateDirectiveModel repeatDirective = new TemplateDirectiveModel() { + @Override + public void execute( + Environment env, + @SuppressWarnings("rawtypes") Map params, + TemplateModel[] loopVars, + TemplateDirectiveBody body) throws TemplateException, IOException { + int times = ((TemplateNumberModel) params.get("times")).getAsNumber().intValue(); + Writer out = env.getOut(); + for (int i = 1; i <= times; i++) { + if (loopVars.length > 0) { + loopVars[0] = new SimpleNumber(i); + } + body.render(out); + } + } + }; + + Map root = new LinkedHashMap<>(); + root.put("repeatFn", repeatMethod); + root.put("repeatDir", repeatDirective); + + String templateSource = "${repeatFn('FM', 3)}|<@repeatDir times=3; idx>[${idx}]"; + String actual = render(cfg, templateSource, root); + Assertions.assertEquals("FM-FM-FM|[1][2][3]", actual); + } + + @Test + void testCustomNumberAndDateFormats() throws Exception { + Configuration cfg = newConfiguration(); + + TemplateNumberFormatFactory hexFormatFactory = new TemplateNumberFormatFactory() { + @Override + public TemplateNumberFormat get(String params, Locale locale, Environment env) + throws TemplateValueFormatException { + TemplateFormatUtil.checkHasNoParameters(params); + return new TemplateNumberFormat() { + @Override + public String formatToPlainText(TemplateNumberModel numberModel) + throws TemplateValueFormatException, TemplateModelException { + return "0x" + Integer.toHexString(TemplateFormatUtil.getNonNullNumber(numberModel).intValue()).toUpperCase(Locale.ROOT); + } + + @Override + public boolean isLocaleBound() { + return false; + } + + @Override + public String getDescription() { + return "hex number format"; + } + }; + } + }; + + TemplateDateFormatFactory epochMillisFormatFactory = new TemplateDateFormatFactory() { + @Override + public TemplateDateFormat get(String params, int dateType, Locale locale, TimeZone timeZone, boolean zonelessInput, Environment env) + throws TemplateValueFormatException { + TemplateFormatUtil.checkHasNoParameters(params); + return new TemplateDateFormat() { + @Override + public String formatToPlainText(TemplateDateModel dateModel) + throws TemplateValueFormatException, TemplateModelException { + return "ms:" + TemplateFormatUtil.getNonNullDate(dateModel).getTime(); + } + + @Override + public Object parse(String s, int dateType) { + return new Date(Long.parseLong(s)); + } + + @Override + public boolean isLocaleBound() { + return false; + } + + @Override + public boolean isTimeZoneBound() { + return false; + } + + @Override + public String getDescription() { + return "epoch millis date format"; + } + }; + } + }; + + cfg.setCustomNumberFormats(Collections.singletonMap("hex", hexFormatFactory)); + cfg.setCustomDateFormats(Collections.singletonMap("epochMillis", epochMillisFormatFactory)); + + Map model = new LinkedHashMap<>(); + model.put("n", 255); + model.put("d", new Date(1700000000000L)); + + String templateSource = "${n?string.@hex}|${d?datetime?string.@epochMillis}|<#setting number_format='@hex'>${4095}"; + Assertions.assertEquals("0xFF|ms:1700000000000|0xFFF", render(cfg, templateSource, model)); + } + + @Test + void testTemplateConfigurationsAndMatchers() throws Exception { + Configuration cfg = newConfiguration(); + StringTemplateLoader loader = new StringTemplateLoader(); + loader.putTemplate("html/page.inc", "

    ${v}

    "); + loader.putTemplate("reports/data.csv", "${flag}|${num}"); + cfg.setTemplateLoader(loader); + + TemplateConfiguration htmlTc = new TemplateConfiguration(); + htmlTc.setOutputFormat(HTMLOutputFormat.INSTANCE); + + TemplateConfiguration csvTc = new TemplateConfiguration(); + csvTc.setBooleanFormat("1,0"); + csvTc.setNumberFormat("0.000"); + + cfg.setTemplateConfigurations(new FirstMatchTemplateConfigurationFactory( + new ConditionalTemplateConfigurationFactory( + new OrMatcher(new FileExtensionMatcher("inc"), new PathGlobMatcher("html/**")), + htmlTc), + new ConditionalTemplateConfigurationFactory( + new PathGlobMatcher("reports/*.csv"), + csvTc) + )); + + Map model = new LinkedHashMap<>(); + model.put("v", ""); + model.put("flag", true); + model.put("num", 3.5); + + try (StringWriter w1 = new StringWriter(); StringWriter w2 = new StringWriter()) { + cfg.getTemplate("html/page.inc").process(model, w1); + cfg.getTemplate("reports/data.csv").process(model, w2); + Assertions.assertEquals("

    <tag>

    ", w1.toString()); + Assertions.assertEquals("1|3.500", w2.toString()); + } + } @Test - public void testMain() { - try { - HelloFreeMarker.main(new String[0]); - Assertions.assertTrue( Boolean.TRUE ); - } catch (Exception e) { - String message = String.format( "Error : %s", e ); - log.error( message , e ); - Assertions.fail( message ); + void testAutoIncludesAutoImportsAndSpecialVariables() throws Exception { + Configuration cfg = newConfiguration(); + StringTemplateLoader loader = new StringTemplateLoader(); + loader.putTemplate("common/auto-header.ftl", "[AUTO:${appTitle}]"); + loader.putTemplate("common/auto-util.ftl", "<#function fmt x><#return '<' + x + '>'>"); + loader.putTemplate( + "main-view.ftl", + "|${u.fmt('val')}|${.current_template_name}|${.main_template_name}|${.data_model.appTitle}|${.version?has_content?c}"); + cfg.setTemplateLoader(loader); + cfg.setAutoIncludes(Collections.singletonList("common/auto-header.ftl")); + cfg.setAutoImports(Collections.singletonMap("u", "common/auto-util.ftl")); + cfg.setLazyAutoImports(true); + + Map model = new LinkedHashMap<>(); + model.put("appTitle", "NativeApp"); + + try (StringWriter out = new StringWriter()) { + cfg.getTemplate("main-view.ftl").process(model, out); + Assertions.assertEquals( + "[AUTO:NativeApp]||main-view.ftl|main-view.ftl|NativeApp|true", + out.toString()); } } + @Test + void testArithmeticEnginesAndExceptionHandlers() throws Exception { + Configuration cfg = newConfiguration(); + cfg.setArithmeticEngine(ArithmeticEngine.CONSERVATIVE_ENGINE); + Assertions.assertEquals( + "10.50|12.35", + render(cfg, "${(10 + 0.5)?string('0.00')}|${12.345?string('0.##;; roundingMode=halfUp')}", Collections.emptyMap())); + + Configuration ignoreCfg = newConfiguration(); + ignoreCfg.setTemplateExceptionHandler(TemplateExceptionHandler.IGNORE_HANDLER); + Assertions.assertEquals("before||after", render(ignoreCfg, "before|${missingVar}|after", Collections.emptyMap())); + + Configuration customHandlerCfg = newConfiguration(); + customHandlerCfg.setTemplateExceptionHandler(new TemplateExceptionHandler() { + @Override + public void handleTemplateException(TemplateException te, Environment env, Writer out) throws TemplateException { + try { + out.write("[ERR:" + te.getBlamedExpressionString() + "]"); + } catch (IOException e) { + throw new TemplateException("Failed to write error", e, env); + } + } + }); + Assertions.assertEquals("A|[ERR:badRef]|B", render(customHandlerCfg, "A|${badRef}|B", Collections.emptyMap())); + } + + @Test + void testXmlDomNodeModel() throws Exception { + String xml = "" + + "FreeMarker In ActionASF" + + "GraalVM NativeOracle" + + ""; + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + Document doc = dbf.newDocumentBuilder().parse(new InputSource(new StringReader(xml))); + + Map root = new LinkedHashMap<>(); + root.put("doc", NodeModel.wrap(doc)); + + String templateSource = "" + + "${doc.library.@name}:" + + "<#list doc.library.book as b>[${b.@id}:${b.title}(${b.author})]|" + + "<#visit doc.library>" + + "<#macro library><#recurse>" + + "<#macro book>${.node.@id}=${.node.title};"; + + Configuration cfg = newConfiguration(); + String actual = render(cfg, templateSource, root); + Assertions.assertEquals( + "Main:[b1:FreeMarker In Action(ASF)][b2:GraalVM Native(Oracle)]|b1=FreeMarker In Action;b2=GraalVM Native;", + actual); + } + + @Test + void testConfigurationSettingsAndLogger() throws Exception { + Configuration cfg = newConfiguration(); + Properties props = new Properties(); + props.setProperty(Configuration.LOCALE_KEY, "en_US"); + props.setProperty(Configuration.TIME_ZONE_KEY, "UTC"); + props.setProperty(Configuration.NUMBER_FORMAT_KEY, "0.00"); + props.setProperty(Configuration.BOOLEAN_FORMAT_KEY, "yes,no"); + props.setProperty(Configuration.ARITHMETIC_ENGINE_KEY, "bigdecimal"); + props.setProperty(Configuration.TEMPLATE_EXCEPTION_HANDLER_KEY, "rethrow"); + props.setProperty(Configuration.API_BUILTIN_ENABLED_KEY, "true"); + props.setProperty(Configuration.OUTPUT_FORMAT_KEY, "HTML"); + cfg.setSettings(props); + + Map model = new LinkedHashMap<>(); + model.put("n", 12.3); + model.put("b", true); + model.put("html", ""); + + Assertions.assertEquals("12.30|yes|<a>", render(cfg, "${n}|${b}|${html}", model)); + + Logger logger = Logger.getLogger(HelloFreeMarkerTest.class.getName()); + Assertions.assertNotNull(logger); + logger.debug("Testing FreeMarker Logger under GraalVM Native Image"); + + Assertions.assertNotNull(Class.forName("freemarker.ext.jython.JythonModel")); + Assertions.assertNotNull(Class.forName("freemarker.ext.jython.JythonWrapper")); + } + } From 8af69a848f58f2929f4763e711e55edc6127b4e1 Mon Sep 17 00:00:00 2001 From: Matteo Franci Date: Sat, 7 Nov 2026 23:00:52 +0100 Subject: [PATCH 3/3] Update CI workflow to use ubuntu-26.04-arm chore: updating ubuntu arm version to ubuntu 26.04 Following public preview https://github.com/actions/runner-images/issues/14226 ubuntu 26 will substitute ubuntu 24 as latest tag too --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccd757ce9..194b4d50c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: build: strategy: matrix: - os: [windows-latest, ubuntu-latest, ubuntu-24.04-arm] + os: [windows-latest, ubuntu-latest, ubuntu-26.04-arm] runs-on: ${{ matrix.os }} concurrency: main_tests_${{ github.ref }}_${{ matrix.os }} steps: