Skip to content

Commit 402b41a

Browse files
authored
Blog Post: How Mill's Executable Jars Work (#4224)
1 parent 03a03a1 commit 402b41a

File tree

3 files changed

+375
-0
lines changed

3 files changed

+375
-0
lines changed

blog/modules/ROOT/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11

2+
* xref:5-executable-jars.adoc[]
23
* xref:4-flaky-tests.adoc[]
34
* xref:3-selective-testing.adoc[]
45
* xref:2-monorepo-build-tool.adoc[]
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
// tag::header[]
2+
3+
# Executable Assembly Jars in Mill
4+
5+
6+
:author: Li Haoyi
7+
:revdate: 2 January 2024
8+
_{author}, {revdate}_
9+
10+
include::mill:ROOT:partial$gtag-config.adoc[]
11+
12+
One feature of the https://mill-build.org[Mill JVM build tool] is that the
13+
assembly jars it creates are directly executable:
14+
15+
```bash
16+
> ./mill show foo.assembly # generate the assembly jar
17+
"ref:v0:bd2c6c70:/Users/lihaoyi/test/out/foo/assembly.dest/out.jar"
18+
19+
> out/foo/assembly.dest/out.jar # run the assembly jar directly
20+
Hello World
21+
```
22+
23+
Other JVM build tools also can generate assemblies, but most need you to run them
24+
via `java -jar` or `java -cp`,
25+
or require you to use https://docs.oracle.com/en/java/javase/11/tools/jlink.html[jlink] or
26+
https://docs.oracle.com/en/java/javase/17/docs/specs/man/jpackage.html[jpackage]
27+
which are much more heavyweight and troublesome to set up. Mill automates that, and while not
28+
groundbreaking, it is a nice convenience that makes your JVM
29+
code built with Mill fit more nicely into command-line centric workflows common in modern
30+
software systems.
31+
32+
This article will discuss how Mill's executable assemblies are implemented, so perhaps
33+
other build tools and toolchains will be able to provide the same convenience
34+
35+
// end::header[]
36+
37+
## Trying Out Mill's Executable Assmeblies
38+
39+
To try out Mill's executable assembly jars, you can reproduce the above steps
40+
with the following code and config:
41+
42+
```scala
43+
// build.mill
44+
import mill._, javalib._
45+
46+
object foo extends JavaModule
47+
```
48+
49+
```java
50+
// foo/src/Foo.java
51+
package foo;
52+
53+
public class Foo{
54+
public static void main(String[] args){
55+
System.out.println("Hello World");
56+
}
57+
}
58+
```
59+
60+
```bash
61+
> ./mill show foo.assembly
62+
"ref:v0:bd2c6c70:/Users/lihaoyi/test/out/foo/assembly.dest/out.jar"
63+
64+
> /Users/lihaoyi/test/out/foo/assembly.dest/out.jar
65+
Hello World
66+
```
67+
68+
Mill's ``JavaModule``s come with `.assembly` tasks built in by default, without needing
69+
to install plugins like is necessary in other build tools like
70+
https://maven.apache.org/plugins/maven-assembly-plugin/usage.html[Maven] or
71+
https://github.com/sbt/sbt-assembly[SBT].
72+
73+
While the above example is a trivial single-module project, this also works for more complicated
74+
projects with multiple modules and third-party dependencies. The assembly jar will aggregate
75+
the code from all upstream modules and dependencies into a single `.jar` file that you can then
76+
execute from the command line.
77+
78+
Most `.jar` files are not directly executable, hence the need for a `java -jar` or `java -cp`
79+
command to run them. To understand how Mill makes direct execution
80+
possible, we first need to understand what a `.jar` file is.
81+
82+
## What is an Assembly Jar?
83+
84+
An "assembly" jar is just a jar file that includes all transitive dependencies.
85+
What makes an assembly different from a "normal" jar is that it should (in theory) contain
86+
everything needed to run you JVM program. In contrast, most "normal" jars do not contain
87+
their dependencies, and you need to separately go download those dependencies and pass them in
88+
via `-classpath`/`-cp` before you can run your Java program.
89+
90+
One thing that many people don't know is that jar files are just zip files. You can see
91+
that from the command line, where although you normally use `jar tf` to list the contents
92+
of a `.jar` file, `unzip -l` works as well:
93+
94+
```bash
95+
> jar tf /Users/lihaoyi/test/out/foo/assembly.dest/out.jar
96+
META-INF/MANIFEST.MF
97+
META-INF/
98+
foo/
99+
foo/Foo.class
100+
```
101+
```bash
102+
> unzip -l /Users/lihaoyi/test/out/foo/assembly.dest/out.jar
103+
Archive: /Users/lihaoyi/test/out/foo/assembly.dest/out.jar
104+
warning [/Users/lihaoyi/test/out/foo/assembly.dest/out.jar]: 203 extra bytes at beginning or within zipfile
105+
(attempting to process anyway)
106+
Length Date Time Name
107+
--------- ---------- ----- ----
108+
110 01-02-2025 12:05 META-INF/MANIFEST.MF
109+
0 01-02-2025 12:05 META-INF/
110+
0 01-02-2025 12:05 foo/
111+
415 01-02-2025 12:05 foo/Foo.class
112+
--------- -------
113+
525 4 files
114+
```
115+
116+
In this case, the example project only has one `Foo.java` source file, compiled into a single
117+
`Foo.class` JVM class file. Larger projects will have multiple class files, including those
118+
from upstream modules and third-party dependencies.
119+
120+
In addition to the compiled class files, jars also can contain metadata. For example, we can see
121+
this generated `out.jar` contains a `META-INF/MANIFEST.MF` file, which contains some basic
122+
metadata including the `Main-Class: foo.Foo` which is the entrypoint of the Java program:
123+
124+
```bash
125+
$ unzip -p /Users/lihaoyi/test/out/foo/assembly.dest/out.jar META-INF/MANIFEST.MF
126+
warning [/Users/lihaoyi/test/out/foo/assembly.dest/out.jar]: 203 extra bytes at beginning or within zipfile
127+
(attempting to process anyway)
128+
Manifest-Version: 1.0
129+
Created-By: Mill 0.12.4-23-2ff492
130+
Tool: Mill-0.12.4-23-2ff492
131+
Main-Class: foo.Foo
132+
```
133+
134+
The `warning: 203 extra bytes at beginning or within zipfile` is a hint that although
135+
this is a valid zip file, something unusual is going on, which leads us to the trick
136+
that we use to make the `out.jar` file executable
137+
138+
## What is a Zip file?
139+
140+
A https://en.wikipedia.org/wiki/ZIP_(file_format)[zip file] is an archive made of multiple smaller files, individually compressed,
141+
concatenated together followed by a "central directory" containing the _reverse offsets_ of
142+
every file within the archive, relative to the central directory.
143+
144+
```graphviz
145+
digraph G {
146+
label="archive.zip"
147+
node [shape=box width=0 height=0 style=filled fillcolor=white]
148+
zip [shape=record label="<f0> Foo.class | <f1> MANIFEST.MF | <f3> ...other files... | <f2> central directory"]
149+
zip:f2:n -> zip:f1:n [label="reverse offsets"]
150+
zip:f2:n -> zip:f0:n
151+
zip:f2:n -> zip:f3:n
152+
}
153+
```
154+
155+
The typical way someone reads from a zip file is a follows:
156+
157+
* Seek to the end of zip and find the central directory
158+
* Find the metadata containing the offset for the file you want
159+
* Seek backwards using that offset to the start of the entry you want
160+
* Read and decompress your entry
161+
162+
Unlike `.tar.gz` files, the entries within a `.zip` file are compressed individually. This
163+
is convenient for use cases like Java classfiles where you want to lazily load them
164+
individually on-demand without having to first decompress the whole archive up front.
165+
166+
## Executable Zip Archives
167+
168+
One quirk of the above Zip format is that _the zip data does not need to start at the
169+
beginning of the file_! The zip data can be at the end of an arbitrarily long file, and
170+
as long as programs can scan to the end of the zip to find the central directory, they
171+
will be able to extract the zip.
172+
173+
```graphviz
174+
digraph G {
175+
node [shape=box width=0 height=0 style=filled fillcolor=white]
176+
label="archive.zip"
177+
extra_label:s -> zip:fe:n [color=red penwidth=3]
178+
extra_label [color=white style=invisible]
179+
zip [shape=record label="<fe> ...extra data... | <f0> Foo.class | <f1> MANIFEST.MF | <f3> ...other files... | <f2> central directory"]
180+
zip:f2:n -> zip:f1:n
181+
zip:f2:n -> zip:f0:n
182+
zip:f2:n -> zip:f3:n
183+
}
184+
```
185+
186+
Thus, we can actually use the `.zip` format in two ways:
187+
188+
1. As a `.zip` file, which is read and extracted starting from the end of the file on the right
189+
2. As something else, such as a bash script, which is read and executed starting from start of the file on the left
190+
191+
This technique is used in common Zip
192+
https://en.wikipedia.org/wiki/Self-extracting_archives[self-extracting archives], where
193+
a short bash script is pre-pended to the zip archive that when run extracts the archive using
194+
`unzip`. Although
195+
this article is about Jars, `.jar` files are really just ``.zip``s with a different name!
196+
So we can prepend a bash script to our `.jar` file to
197+
198+
* Run `java` with the current executable `"$0"` as the classpath
199+
* Pass any of the current executable's command-line arguments `"$@"`as the Java program's command-line arguments
200+
* Allow configuration of the `java` process (since we're no longer calling it ourselves) via a `JAVA_OPTS` environment variable
201+
202+
```graphviz
203+
digraph G {
204+
label="out.jar"
205+
left [shape=plaintext label="bash script starts executing at start of file\nruns `java` passing itself as the classpath"]
206+
right [shape=plaintext label="`java` loads compiled classfiles from jar/zip\nby reading the central directory at end of file"]
207+
208+
node [shape=box width=0 height=0 style=filled fillcolor=white]
209+
zip [shape=record label="<fe> exec java $JAVA_OPTS -cp \"$0\" 'foo.Foo' \"$@\" | <f0> Foo.class | <f1> MANIFEST.MF | <f3>...other files... | <f2> central directory"]
210+
zip:f2:n -> zip:f1:n
211+
zip:f2:n -> zip:f0:n
212+
zip:f2:n -> zip:f3:n
213+
left -> zip:fe:n [color=red penwidth=3]
214+
zip:f2:s -> right [dir=back color=red penwidth=3]
215+
}
216+
```
217+
218+
If you use `less out.jar` to look at what's inside the Jar file, it looks like this:
219+
220+
```bash
221+
exec java $JAVA_OPTS -cp "$0" 'foo.Foo' "$@"
222+
PK^C^D^T^@^H^H^H^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@^T^@^Q^@META-INF/MANIFEST.MFUT^M^@^G<97>^Pvg<97>^Pvg<97>^Pvgeɱ
223+
<80> ^P^@<D0><FD><C0>^?<B8>^_81s<C9>1<A1><CD>-<DA>^OR^P<C4>^Cu<E9><EF>ESC^Z{<EB><8B><DC>JNcҕ<FA>(<D2><.<DA>=<F1>L7<ED><8F><C7>XjE<A3>^W<AB>^]ٕl<CE>n<B3>
224+
N<91><FA>%<FD>3ri^T*<8F><E1>1<8B><E8>CD<81><82>^WPK^G^HB?^Xo[^@^@^@n^@^@^@PK^C^D
225+
^@^@^@^@^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@ ^@^Q^@META-INF/UT^M^@^G<97>^Pvg<97>^Pvg<97>^PvgPK^C^D
226+
^@^@^@^@^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@^D^@^Q^@foo/UT^M^@^G<97>^Pvg<97>^Pvg<97>^PvgPK^C^D^T^@^H^H^H^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@^M^@^Q^@foo/Foo.classUT^M^@^G<97>^Pvg<97>^Pvg<97>^Pvgm<90><CB>J<C3>@^T<86><FF><D3>[<9A>4<DA><DA><DA>z-<E8>BH]<<98>^G<A8><BA>^Q<8A><8B><A0>B<A4>.\<A5><ED>X<A6>L2^R^S<C1><C7>҅<82>^K^_<C0><87>^R<CF>^DA<85><CE><E2><DC><E6><FB><FF>^C<E7><<F3><EB><FD>^C<C0> <FA>^NJ([<A8><B8><A8><A2>Fh-<A2><C7><C8>WQ2<F7>/'^K1<CD>^H<B5>c<99><C8><EC><94>P<F6>^FcESCu<D8>^V^^\^W^M<B8><FF><F0><F0><E9>!^S1S:gQ7(~<A4><F6><AF>R<99>da<96><8A>(^^ֱJh<9C>^K<A5><F4>ލN<D5><CC>A^Kk^V<DA>.:X't<96><88>^Hֽ<E9>T®^<F0>ga<C6><E3><F9>p0<B6><D0>c<E8>Nk^?<A4>5<A1>r<A6>g<82><D0>^Ld".<F2>x"<D2><EB>h<A2>xR<89>#<C9>&=<EF>v<99>^K<C1> u<<9E>N<C5>H^Z<B8><CE>^G^F<C3>><BA>|#<F3>J s%<8E>ESC<DC><F5>9^S<E7><EA><E1>ESC<E8><99>^K<C2>&<C7>Z1,<C3><C6>^V<B6>^?ЃB
227+
<D8>/<B0><DA>+<AF>h<FE><E2>N<E1>]<E5><B3>^Z<E1>N<B1>e<F7>ESCPK^G^H<94>r+6 ^A^@^@<9F>^A^@^@PK^A^B^T^@^T^@^H^H^H^@<B5>`"ZB?^^Xo[^@^@^@n^@^@^@^T^@ ^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@META-INF/MANIFEST.MFUT^E^@^G<97>^PvgPK^A^B
228+
^@
229+
^@^@^@^@^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@ ^@ ^@^@^@^@^@^@^@^@^@^@^@<AE>^@^@^@META-INF/UT^E^@^G<97>^PvgPK^A^B
230+
^@
231+
^@^@^@^@^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@^D^@ ^@^@^@^@^@^@^@^@^@^@^@<E6>^@^@^@foo/UT^E^@^G<97>^PvgPK^A^B^T^@^T^@^H^H^H^@<B5>`"Z<94>r+6 ^A^@^@<9F>^A^@^@^M^@ ^@^@^@^@^@^@^@^@^@^@^@^Y^A^@^@foo/Foo.classUT^E^@^G<97>^PvgPK^E^F^@^@^@^@^D^@^D^@
232+
^A^@^@<85>^B^@^@^@^@
233+
/Users/lihaoyi/test/out/foo/assembly.dest/out.jar (END)
234+
```
235+
236+
Here, you can see a single line of `exec java $JAVA_OPTS -cp "$0" 'foo.Foo' "$@"` which
237+
is the bash script we prepended to the zip, followed by the un-intelligible compressed
238+
class file data that makes up the `.jar`. Since now you are running the Java program
239+
via `./out.jar` instead of `java -jar`, we expose the `JAVA_OPTS` environment variable
240+
as a way to pass flags to the `java` command that ends up being run.]
241+
242+
## What about Windows?
243+
244+
The self-executing jar file above works by prepending a shell script. This works on Unix
245+
environments like Linux or Mac, but not on the Windows machines which are also very common.
246+
247+
To fix this, we can replace our shell script zip prefix with a "universal" script that
248+
is both a valid `.sh` program as well as valid `.bat` program, the latter being the
249+
standard windows command line language. Thus, instead of:
250+
251+
```bash
252+
exec java $JAVA_OPTS -cp "$0" 'foo.Foo' "$@"
253+
```
254+
255+
We can instead use:
256+
257+
```bash
258+
@ 2>/dev/null # 2>nul & echo off & goto BOF
259+
:
260+
exec java $JAVA_OPTS -cp "$0" 'foo.Foo' "$@"
261+
exit
262+
263+
:BOF
264+
setlocal
265+
@echo off
266+
java %JAVA_OPTS% -cp "%~dpnx0" foo.Foo %*
267+
endlocal
268+
exit /B %errorlevel%
269+
```
270+
271+
272+
This universal launcher script is worth digging into.
273+
274+
In a `sh` shell:
275+
276+
* `@ 2>/dev/null # 2>nul & echo off & goto BOF` is an invalid command, but we ignore
277+
the error because we pipe it to `/dev/null`
278+
279+
* It then runs the `exec java -cp` command
280+
281+
* We `exit` the script before we hit the invalid shell code below
282+
283+
In a `bat` environment:
284+
285+
* We run the first line, doing nothing, until we hit `goto BOF`. This jumps over the `exec java`
286+
line which is not valid `bat` code, to go straight to the `:BOF` label
287+
288+
* We then run `java -cp`, but with slightly different syntax from the unix/shell version above
289+
(e.g. `%~dpnx0` instead of `$0`) for windows/bat compatibility
290+
291+
* We then `exit` the script, using `/B %errorlevel%` which is the windows syntax for propagating
292+
the exit code, before we hit the compressed data below which is not valid `bat` code.
293+
294+
As a result, we have a short script that we can call either from `sh` or `bat`,
295+
that forwards arguments and the script itself (which is also a `.jar` file) to `java -cp`,
296+
and then forwards the exit code back from `java -cp` to the caller. Although the script may
297+
look fragile, the strong backwards compatibility of `.sh` and `.bat` scripts means that
298+
once working it is unlikely to break in future versions of Mac/Linux/Windows.
299+
300+
If we look at the file using `less -n20`, we can now see our universal launcher script
301+
pre-pended to the blobs of compressed classfile data that make up the rest of the jar:
302+
303+
```bash
304+
@ 2>/dev/null # 2>nul & echo off & goto BOF
305+
:
306+
exec java $JAVA_OPTS -cp "$0" 'foo.Foo' "$@"
307+
exit
308+
309+
:BOF
310+
setlocal
311+
@echo off
312+
java %JAVA_OPTS% -cp "%~dpnx0" foo.Foo %*
313+
endlocal
314+
exit /B %errorlevel%
315+
PK^C^D^T^@^H^H^H^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@^T^@^Q^@META-INF/MANIFEST.MFUT^M^@^G<97>^Pvg<97>^Pvg<97>^Pvgeɱ
316+
<80> ^P^@<D0><FD><C0>^?<B8>^_81s<C9>1<A1><CD>-<DA>^OR^P<C4>^Cu<E9><EF>ESC^Z{<EB><8B><DC>JNcҕ<FA>(<D2><.<DA>=<F1>L7<ED><8F><C7>XjE<A3>^W<AB>^]ٕl<CE>n<B3>
317+
N<91><FA>%<FD>3ri^T*<8F><E1>1<8B><E8>CD<81><82>^WPK^G^HB?^Xo[^@^@^@n^@^@^@PK^C^D
318+
^@^@^@^@^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@ ^@^Q^@META-INF/UT^M^@^G<97>^Pvg<97>^Pvg<97>^PvgPK^C^D
319+
^@^@^@^@^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@^D^@^Q^@foo/UT^M^@^G<97>^Pvg<97>^Pvg<97>^PvgPK^C^D^T^@^H^H^H^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@^M^@^Q^@foo/Foo.classUT^M^@^G<97>^Pvg<97>^Pvg<97>^Pvgm<90><CB>J<C3>@^T<86><FF><D3>[<9A>4<DA><DA><DA>z-<E8>BH]<<98>^G<A8><BA>^Q<8A><8B><A0>B<A4>.\<A5><ED>X<A6>L2^R^S<C1><C7>҅<82>^K^_<C0><87>^R<CF>^DA<85><CE><E2><DC><E6><FB><FF>^C<E7><<F3><EB><FD>^C<C0> <FA>^NJ([<A8><B8><A8><A2>Fh-<A2><C7><C8>WQ2<F7>/'^K1<CD>^H<B5>c<99><C8><EC><94>P<F6>^FcESCu<D8>^V^^\^W^M<B8><FF><F0><F0><E9>!^S1S:gQ7(~<A4><F6><AF>R<99>da<96><8A>(^^ֱJh<9C>^K<A5><F4>ލN<D5><CC>A^Kk^V<DA>.:X't<96><88>^Hֽ<E9>T®^<F0>ga<C6><E3><F9>p0<B6><D0>c<E8>Nk^?<A4>5<A1>r<A6>g<82><D0>^Ld".<F2>x"<D2><EB>h<A2>xR<89>#<C9>&=<EF>v<99>^K<C1> u<<9E>N<C5>H^Z<B8><CE>^G^F<C3>><BA>|#<F3>J s%<8E>ESC<DC><F5>9^S<E7><EA><E1>ESC<E8><99>^K<C2>&<C7>Z1,<C3><C6>^V<B6>^?ЃB
320+
<D8>/<B0><DA>+<AF>h<FE><E2>N<E1>]<E5><B3>^Z<E1>N<B1>e<F7>ESCPK^G^H<94>r+6 ^A^@^@<9F>^A^@^@PK^A^B^T^@^T^@^H^H^H^@<B5>`"ZB?^^Xo[^@^@^@n^@^@^@^T^@ ^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@META-INF/MANIFEST.MFUT^E^@^G<97>^PvgPK^A^B
321+
^@
322+
^@^@^@^@^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@ ^@ ^@^@^@^@^@^@^@^@^@^@^@<AE>^@^@^@META-INF/UT^E^@^G<97>^PvgPK^A^B
323+
^@
324+
^@^@^@^@^@<B5>`"Z^@^@^@^@^@^@^@^@^@^@^@^@^D^@ ^@^@^@^@^@^@^@^@^@^@^@<E6>^@^@^@foo/UT^E^@^G<97>^PvgPK^A^B^T^@^T^@^H^H^H^@<B5>`"Z<94>r+6 ^A^@^@<9F>^A^@^@^M^@ ^@^@^@^@^@^@^@^@^@^@^@^Y^A^@^@foo/Foo.classUT^E^@^G<97>^PvgPK^E^F^@^@^@^@^D^@^D^@
325+
^A^@^@<85>^B^@^@^@^@
326+
/Users/lihaoyi/test/out/foo/assembly.dest/out.jar (END)
327+
```
328+
329+
We can run it directly on Mac/Linux:
330+
331+
```bash
332+
> ./mill show foo.assembly # generate the assembly jar
333+
"ref:v0:bd2c6c70:/Users/lihaoyi/test/out/foo/assembly.dest/out.jar"
334+
335+
> out/foo/assembly.dest/out.jar # run the assembly jar directly
336+
Hello World
337+
```
338+
339+
And we can run it on windows, although we need to rename
340+
`out.jar` to `out.bat` before executing it:
341+
342+
```bash
343+
> ./mill show foo.assembly
344+
"ref:v0:bd2c6c70:C:\\Users\\haoyi\\test\\out\\foo\\assembly.dest\\out.jar"
345+
346+
> cp out\foo\assembly.dest\out.jar out.bat
347+
348+
> ./out.bat
349+
Hello World
350+
```
351+
352+
## Conclusion
353+
354+
The executable assembly jars that Mill generates are very convenient; it means that
355+
you can use Mill to compile (almost) any Java program into an executable you can run with
356+
`./out.jar`, as long as you have the appropriate version of Java globally installed. This
357+
is much easier than setting up JLink or JPackage. You can even have an executable jar that
358+
runs on all of Mac/Linux/Windows just by carefully crafting a launcher script that runs
359+
on all platforms.
360+
361+
The Mill JVM build tool provides these executable assembly jars out-of-the-box, the SBT
362+
build tool as part of the https://github.com/sbt/sbt-assembly[SBT Assembly] plugin,
363+
via the `prependShellScript` config.
364+
Maven and Gradle do not provide this by default but it is pretty easy to set up yourself
365+
simply by concatenating a shell script with an assembly jar, as described above.
366+
367+
Although running Java programs via
368+
`java -jar` or `java -cp` is not a huge hardship, removing that friction really helps your
369+
Java programs and codebase feel like a first class citizen on the command-line.
370+

blog/modules/ROOT/pages/index.adoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ technical topics related to JVM platform tooling and language-agnostic build too
88
some specific to the Mill build tool but mostly applicable to anyone working on
99
build tooling for large codebases in JVM and non-JVM languages.
1010

11+
include::5-executable-jars.adoc[tag=header,leveloffset=1]
12+
13+
xref:5-executable-jars.adoc[Read More...]
14+
1115
include::4-flaky-tests.adoc[tag=header,leveloffset=1]
1216

1317
xref:4-flaky-tests.adoc[Read More...]

0 commit comments

Comments
 (0)