Skip to content

Commit b22650b

Browse files
fvarruiclaude
andcommitted
Check the Velocity templates in CI
New checkTemplates task (also run by check) and CI job: every template must parse, and the GNU/Linux and macOS startup scripts, rendered with sample settings, must be valid Bash; the jreMinVersion comparison is tested on the rendered script (#505). Bash checks are skipped on Windows. Breaking the #[[ ]]# block in startup.sh.vtl fails with the template, line and column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 29e3392 commit b22650b

7 files changed

Lines changed: 291 additions & 24 deletions

File tree

‎.github/workflows/smoke-tests.yml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ on:
88
workflow_dispatch:
99

1010
jobs:
11+
templates:
12+
name: templates
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 10
15+
16+
steps:
17+
- uses: actions/checkout@v5
18+
19+
- uses: actions/setup-java@v5
20+
with:
21+
distribution: temurin
22+
java-version: 17
23+
cache: gradle
24+
25+
# every Velocity template parses, and the startup scripts render to valid Bash
26+
- name: Check templates
27+
run: ./gradlew checkTemplates --no-daemon
28+
1129
macos:
1230
name: ${{ matrix.os }} / ${{ matrix.tool }} / ${{ matrix.macStartup }}${{ matrix.admin && ' / admin' || '' }}
1331
runs-on: ${{ matrix.os }}

‎.minispec/core/conventions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
- Platform-specific options go in `WindowsConfig`, `MacConfig` or `LinuxConfig`, not in `PackagerSettings`.
1010
- New artifacts are `ArtifactGenerator` subclasses. Platform and external-tool checks go in `skip()`, not in `doApply()`.
1111
- Template names and the variables they use are public API: users override them from `assetsDir`. Renaming a template or changing its variables is a breaking change.
12-
- In `.vtl` templates, Velocity reads bash `${name[@]}` as its own reference and fails: wrap such lines in `#[[ ... ]]#` (they must not contain Velocity references). `${#name[@]}` is fine.
12+
- In `.vtl` templates, Velocity reads bash `${name[@]}` as its own reference and fails: wrap such lines in `#[[ ... ]]#` (they must not contain Velocity references). `${#name[@]}` is fine. `./gradlew checkTemplates` (CI job `templates`) parses every template and checks the rendered startup scripts with `bash -n`; extend `src/templateCheck` when a shell template gains settings.
1313
- Dependency versions: Maven consumers get the version declared in `build.gradle` (nearest wins), Gradle the highest in the graph. Declare the version Gradle resolves, so both use the same jar (`check-deps` skill).
1414
- The published POM is edited in `pom.withXml`: Maven APIs (`maven-plugin-api`, `maven-core`) are `provided`, Maven plugins used as libraries (`mojo-executor`, `jdeb`) exclude `org.apache.maven:*`, and Gradle-only dependencies (the launch4j Gradle library) and repositories stay out. Gradle consumers use the `.module` file instead.
1515
- JDK-dependent options (`jlink`, `jdeps`) follow the packaging JDK's version (`JDKUtils.getJavaMajorVersion(File)`), not the Java running Maven or Gradle (`jdeps` still uses the running one). JDK 24+ may come without `jmods` (JEP 493).

‎.minispec/features/ci-velocity-template-check.md‎

Lines changed: 0 additions & 21 deletions
This file was deleted.

‎.minispec/features/release-2-0-1.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Fix the bugs found while documenting 2.0.0 (#501, #502, #504 to #510), clear the
77
## Context
88

99
- The docs review (2026-10-11) checked every property against the code and found the bugs below; each one has its own issue.
10-
- `dependabot-alerts.md` and `ci-velocity-template-check.md` hold the other two parts.
10+
- `dependabot-alerts.md` holds the Dependabot part; the template check is done (`./gradlew checkTemplates`, CI job `templates`).
1111

1212
## Changes
1313

@@ -16,7 +16,7 @@ Fix the bugs found while documenting 2.0.0 (#501, #502, #504 to #510), clear the
1616
| #501 | `iconFile` is used when no platform icon is set and it has the target platform's format (`.png`, `.icns`, `.ico`); otherwise a warning. Forwarded by the Gradle `PackageTask`. | Code review |
1717
| #502 | Gradle `PackageTask` forwards `zipballName` and `tarballName`; Gradle `CreateZipball` appends `.zip` like Maven. | Code review |
1818
| #504 | `msiUpgradeCode` defaults to a name-based UUID (organization and name), logged; `fileVersion`/`productVersion` default to `version` as `x.x.x.x` (or `1.0.0.0` with a warning if it doesn't fit an MSI version); `AllowSameVersionUpgrades="yes"`. | jshell test of the version conversion; Windows smoke tests build the MSI |
19-
| #505 | Linux `compareVersions()` compares the leading digits of each part numerically, `1.x` as `x`; wrapped in `#[[ ]]#`. | Bash test of 9 cases (17 vs 8, 9 vs 11, 1.8 vs 8...) |
19+
| #505 | Linux `compareVersions()` compares the leading digits of each part numerically, `1.x` as `x`; wrapped in `#[[ ]]#`. | `checkTemplates` (CI job `templates`) runs 8 cases (17 vs 8, 9 vs 11, 1.8 vs 8...) on the rendered script |
2020
| #506 | `GDK_BACKEND=x11` in the `pkexec` launch. | Code review |
2121
| #507 | The Linux script `exec`s Java instead of `exit 0`. | Linux smoke test: `--exit=3` returns 3 (the sample exits with the code given) |
2222
| #508 | macOS bundle permission rules use `${jreDirectoryName}.jre` (Gradle tasks and `mac/assembly.xml.vtl`); Gradle also sets 755 on `lib/jspawnhelper`. | macOS smoke test: `bin/java` and `jspawnhelper` are 755 in the tarball |

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Use the wrapper (`./gradlew` or `gradlew.bat` on Windows).
1616
| --- | --- |
1717
| `./gradlew build` | Compile, generate the Maven plugin descriptor and build the jars |
1818
| `./gradlew clean build` | Rebuild from scratch when the Maven plugin descriptor looks stale |
19+
| `./gradlew checkTemplates` | Check that every Velocity template parses and the startup scripts render to valid Bash (also run by `check`; Bash checks skipped on Windows) |
1920
| `./gradlew publishToMavenLocal` | Install the plugin into `~/.m2` to test it from a sample Maven/Gradle project |
2021
| `./gradlew updateNativeJavaApplicationStub` | Re-download the native macOS launchers (`nativeJavaApplicationStub*`) into `src/main/resources/mac` from a [javapackager/nativeJavaApplicationStub](https://github.com/javapackager/nativeJavaApplicationStub) release |
2122
| `./gradlew updateWhyJavaLauncher` | Re-download `JavaLauncher.exe` into `src/main/resources/windows` |

‎build.gradle‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,31 @@ tasks.register('updateWhyJavaLauncher', Download) {
269269
dest file('src/main/resources/windows')
270270
overwrite true
271271
}
272+
273+
// checkTemplates: every Velocity template parses, and the shell templates render to valid Bash (src/templateCheck)
274+
sourceSets {
275+
templateCheck
276+
}
277+
278+
configurations {
279+
templateCheckImplementation.extendsFrom implementation
280+
}
281+
282+
compileTemplateCheckJava.options.encoding = 'UTF-8'
283+
compileTemplateCheckJava.options.compilerArgs << '-Xlint:-options'
284+
285+
tasks.register('checkTemplates', JavaExec) {
286+
group = 'verification'
287+
description = 'Checks that every Velocity template parses and the shell templates render to valid Bash (Bash checks skipped on Windows).'
288+
classpath = sourceSets.templateCheck.runtimeClasspath
289+
mainClass = 'io.github.javapackager.TemplateCheck'
290+
def templates = file('src/main/resources')
291+
def output = layout.buildDirectory.dir('template-check')
292+
inputs.dir(templates)
293+
outputs.dir(output)
294+
args templates.absolutePath, output.get().asFile.absolutePath
295+
}
296+
297+
tasks.named('check') {
298+
dependsOn 'checkTemplates'
299+
}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package io.github.javapackager;
2+
3+
import java.io.File;
4+
import java.io.IOException;
5+
import java.io.StringWriter;
6+
import java.nio.charset.StandardCharsets;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
import java.nio.file.Paths;
10+
import java.util.ArrayList;
11+
import java.util.Arrays;
12+
import java.util.Collections;
13+
import java.util.HashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.stream.Collectors;
17+
import java.util.stream.Stream;
18+
19+
import org.apache.velocity.Template;
20+
import org.apache.velocity.VelocityContext;
21+
import org.apache.velocity.app.VelocityEngine;
22+
import org.apache.velocity.runtime.RuntimeConstants;
23+
import org.apache.velocity.runtime.resource.loader.FileResourceLoader;
24+
25+
/**
26+
* Checks the plugin's Velocity templates (run by the checkTemplates Gradle task):
27+
* every template must parse, and the shell templates, rendered with sample settings,
28+
* must be valid Bash. The Bash checks are skipped on Windows.
29+
* <p>
30+
* Usage: TemplateCheck &lt;templates folder&gt; &lt;output folder&gt;
31+
*/
32+
public class TemplateCheck {
33+
34+
private static final List<String> failures = new ArrayList<>();
35+
36+
public static void main(String[] args) throws Exception {
37+
File templatesDir = new File(args[0]);
38+
File outputDir = new File(args[1]);
39+
outputDir.mkdirs();
40+
41+
VelocityEngine engine = new VelocityEngine();
42+
engine.setProperty(RuntimeConstants.RESOURCE_LOADERS, "file");
43+
engine.setProperty("resource.loader.file.class", FileResourceLoader.class.getName());
44+
engine.setProperty("resource.loader.file.path", templatesDir.getAbsolutePath());
45+
engine.init();
46+
47+
List<String> templates;
48+
try (Stream<Path> files = Files.walk(templatesDir.toPath())) {
49+
templates = files
50+
.filter(p -> p.toString().endsWith(".vtl"))
51+
.map(p -> templatesDir.toPath().relativize(p).toString().replace('\\', '/'))
52+
.sorted()
53+
.collect(Collectors.toList());
54+
}
55+
56+
// 1. every template parses
57+
for (String template : templates) {
58+
try {
59+
engine.getTemplate(template, "UTF-8");
60+
System.out.println("PASS: parses: " + template);
61+
} catch (Exception e) {
62+
fail(template + " doesn't parse: " + e.getMessage());
63+
}
64+
}
65+
66+
// 2. shell templates are valid Bash with different settings
67+
boolean bash = !System.getProperty("os.name").toLowerCase().startsWith("windows");
68+
if (!bash) {
69+
System.out.println("SKIP: Bash checks (Windows)");
70+
}
71+
for (Map<String, Object> info : linuxVariants()) {
72+
File script = render(engine, "linux/startup.sh.vtl", info, new File(outputDir, "startup-" + info.get("variant") + ".sh"));
73+
if (bash && script != null) {
74+
bashSyntax(script);
75+
if (info.get("jreMinVersion") != null) {
76+
compareVersionsTests(script, outputDir);
77+
}
78+
}
79+
}
80+
for (Map<String, Object> info : macVariants()) {
81+
File script = render(engine, "mac/startup.vtl", info, new File(outputDir, "mac-startup-" + info.get("variant") + ".sh"));
82+
if (bash && script != null) {
83+
bashSyntax(script);
84+
}
85+
}
86+
87+
if (!failures.isEmpty()) {
88+
System.err.println(failures.size() + " template check(s) failed:");
89+
failures.forEach(f -> System.err.println(" " + f));
90+
System.exit(1);
91+
}
92+
System.out.println("All template checks passed");
93+
}
94+
95+
private static List<Map<String, Object>> linuxVariants() {
96+
// bundled JRE, default settings
97+
Map<String, Object> bundled = linuxInfo("bundled-jre");
98+
// system Java with a minimum version, administrator, classpath, bootstrap script, envPath, no wrapped JAR
99+
Map<String, Object> full = linuxInfo("system-java");
100+
full.put("bundleJre", false);
101+
full.put("jreMinVersion", "11");
102+
full.put("administratorRequired", true);
103+
full.put("classpath", "extra:lib/other.jar");
104+
full.put("classpaths", Arrays.asList("extra", "lib/other.jar"));
105+
full.put("envPath", "/usr/local/bin:$PATH");
106+
full.put("scripts", map("bootstrap", "bootstrap.sh"));
107+
full.put("bootstrapFile", map("name", "bootstrap.sh"));
108+
full.put("linuxConfig", map("wrapJar", false));
109+
full.put("useResourcesAsWorkingDir", false);
110+
return Arrays.asList(bundled, full);
111+
}
112+
113+
private static Map<String, Object> linuxInfo(String variant) {
114+
Map<String, Object> info = new HashMap<>();
115+
info.put("variant", variant);
116+
info.put("name", "HelloWorld");
117+
info.put("mainClass", "com.example.Main");
118+
info.put("bundleJre", true);
119+
info.put("jreDirectoryName", "jre");
120+
info.put("administratorRequired", false);
121+
info.put("useResourcesAsWorkingDir", true);
122+
info.put("linuxConfig", map("wrapJar", true));
123+
info.put("executable", map("name", "HelloWorld"));
124+
info.put("jarFile", map("name", "HelloWorld-1.0.0-runnable.jar"));
125+
info.put("vmArgs", Arrays.asList("-Xmx1g", "-Dapp.title=Hello World"));
126+
info.put("appArgs", Arrays.asList("--foo", "hello world"));
127+
info.put("classpaths", Collections.emptyList());
128+
info.put("scripts", new HashMap<>());
129+
return info;
130+
}
131+
132+
private static List<Map<String, Object>> macVariants() {
133+
Map<String, Object> info = new HashMap<>();
134+
info.put("variant", "admin");
135+
info.put("name", "HelloWorld");
136+
info.put("launcher", map("name", "universalJavaApplicationStub"));
137+
return Collections.singletonList(info);
138+
}
139+
140+
private static Map<String, Object> map(String key, Object value) {
141+
Map<String, Object> map = new HashMap<>();
142+
map.put(key, value);
143+
return map;
144+
}
145+
146+
private static File render(VelocityEngine engine, String templatePath, Map<String, Object> info, File output) {
147+
try {
148+
VelocityContext context = new VelocityContext();
149+
context.put("info", info);
150+
Template template = engine.getTemplate(templatePath, "UTF-8");
151+
StringWriter writer = new StringWriter();
152+
template.merge(context, writer);
153+
Files.write(output.toPath(), writer.toString().replace("\r\n", "\n").getBytes(StandardCharsets.UTF_8));
154+
System.out.println("PASS: renders: " + templatePath + " (" + info.get("variant") + ")");
155+
return output;
156+
} catch (Exception e) {
157+
fail(templatePath + " (" + info.get("variant") + ") doesn't render: " + e.getMessage());
158+
return null;
159+
}
160+
}
161+
162+
private static void bashSyntax(File script) throws IOException, InterruptedException {
163+
Result result = run("bash", "-n", script.getAbsolutePath());
164+
if (result.code == 0) {
165+
System.out.println("PASS: valid Bash: " + script.getName());
166+
} else {
167+
fail(script.getName() + " is not valid Bash: " + result.output);
168+
}
169+
}
170+
171+
/**
172+
* Runs the startup script's compareVersions() with known Java and minimum versions (#505)
173+
*/
174+
private static void compareVersionsTests(File script, File outputDir) throws IOException, InterruptedException {
175+
String content = new String(Files.readAllBytes(script.toPath()), StandardCharsets.UTF_8);
176+
int start = content.indexOf("function compareVersions()");
177+
int end = content.indexOf("\n}\n", start);
178+
if (start < 0 || end < 0) {
179+
fail(script.getName() + ": compareVersions() not found");
180+
return;
181+
}
182+
String[][] cases = {
183+
// java version, minimum version, expected result (0 = enough, 1 = too old)
184+
{ "17.0.9", "8", "0" },
185+
{ "9", "11", "1" },
186+
{ "1.8.0_392", "8", "0" },
187+
{ "1.8.0_392", "11", "1" },
188+
{ "11.0.2", "11", "0" },
189+
{ "21", "17.0.1", "0" },
190+
{ "17-ea", "17", "0" },
191+
{ "17.0.9", "17.0.10", "1" },
192+
};
193+
StringBuilder test = new StringBuilder(content.substring(start, end + 3));
194+
test.append("failed=0\n");
195+
for (String[] c : cases) {
196+
test.append("compareVersions '").append(c[0]).append("' '").append(c[1]).append("'; r=$?; ")
197+
.append("[ \"$r\" = ").append(c[2]).append(" ] || { echo 'java ").append(c[0]).append(" vs ").append(c[1])
198+
.append(": got '$r', expected ").append(c[2]).append("'; failed=1; }\n");
199+
}
200+
test.append("exit $failed\n");
201+
Path testFile = Paths.get(outputDir.getAbsolutePath(), "compare-versions-test.sh");
202+
Files.write(testFile, test.toString().getBytes(StandardCharsets.UTF_8));
203+
Result result = run("bash", testFile.toString());
204+
if (result.code == 0) {
205+
System.out.println("PASS: compareVersions() gives the expected results (" + cases.length + " cases)");
206+
} else {
207+
fail("compareVersions() gives wrong results: " + result.output);
208+
}
209+
}
210+
211+
private static Result run(String... command) throws IOException, InterruptedException {
212+
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
213+
String output = new String(readAll(process), StandardCharsets.UTF_8).trim();
214+
return new Result(process.waitFor(), output);
215+
}
216+
217+
private static byte[] readAll(Process process) throws IOException {
218+
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
219+
byte[] buffer = new byte[4096];
220+
int n;
221+
while ((n = process.getInputStream().read(buffer)) != -1) {
222+
out.write(buffer, 0, n);
223+
}
224+
return out.toByteArray();
225+
}
226+
227+
private static void fail(String message) {
228+
System.out.println("FAIL: " + message);
229+
failures.add(message);
230+
}
231+
232+
private static class Result {
233+
final int code;
234+
final String output;
235+
Result(int code, String output) {
236+
this.code = code;
237+
this.output = output;
238+
}
239+
}
240+
241+
}

0 commit comments

Comments
 (0)