• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jreleaser / jreleaser / #521

29 Jul 2025 12:08PM UTC coverage: 45.469% (-0.05%) from 45.518%
#521

push

github

web-flow
feat(extensions): Support JBang as extension loader and launcher

Closed #1952

1 of 49 new or added lines in 6 files covered. (2.04%)

9 existing lines in 2 files now uncovered.

24041 of 52873 relevant lines covered (45.47%)

0.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

19.7
/core/jreleaser-engine/src/main/java/org/jreleaser/extensions/internal/DefaultExtensionManager.java
1
/*
2
 * SPDX-License-Identifier: Apache-2.0
3
 *
4
 * Copyright 2020-2025 The JReleaser authors.
5
 *
6
 * Licensed under the Apache License, Version 2.0 (the "License");
7
 * you may not use this file except in compliance with the License.
8
 * You may obtain a copy of the License at
9
 *
10
 *     https://www.apache.org/licenses/LICENSE-2.0
11
 *
12
 * Unless required by applicable law or agreed to in writing, software
13
 * distributed under the License is distributed on an "AS IS" BASIS,
14
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
 * See the License for the specific language governing permissions and
16
 * limitations under the License.
17
 */
18
package org.jreleaser.extensions.internal;
19

20
import org.apache.commons.io.IOUtils;
21
import org.jreleaser.bundle.RB;
22
import org.jreleaser.extensions.api.Extension;
23
import org.jreleaser.extensions.api.ExtensionManager;
24
import org.jreleaser.extensions.api.ExtensionPoint;
25
import org.jreleaser.extensions.api.workflow.WorkflowListener;
26
import org.jreleaser.model.JReleaserException;
27
import org.jreleaser.model.internal.JReleaserContext;
28
import org.jreleaser.sdk.command.CommandException;
29
import org.jreleaser.sdk.tool.JBang;
30
import org.jreleaser.sdk.tool.Mvn;
31
import org.jreleaser.sdk.tool.ToolException;
32
import org.jreleaser.templates.TemplateResource;
33
import org.jreleaser.templates.TemplateUtils;
34
import org.jreleaser.util.DefaultVersions;
35
import org.jreleaser.util.FileUtils;
36
import org.kordamp.jipsy.annotations.ServiceProviderFor;
37

38
import java.io.File;
39
import java.io.IOException;
40
import java.net.MalformedURLException;
41
import java.net.URL;
42
import java.net.URLClassLoader;
43
import java.nio.file.Files;
44
import java.nio.file.Path;
45
import java.nio.file.Paths;
46
import java.util.ArrayList;
47
import java.util.Collections;
48
import java.util.LinkedHashMap;
49
import java.util.LinkedHashSet;
50
import java.util.List;
51
import java.util.Map;
52
import java.util.Optional;
53
import java.util.ServiceLoader;
54
import java.util.Set;
55
import java.util.stream.Stream;
56

57
import static java.nio.charset.StandardCharsets.UTF_8;
58
import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
59
import static java.nio.file.StandardOpenOption.WRITE;
60
import static java.util.stream.Collectors.toList;
61
import static org.jreleaser.util.StringUtils.isBlank;
62
import static org.jreleaser.util.StringUtils.isNotBlank;
63

64
/**
65
 * @author Andres Almiray
66
 * @since 1.3.0
67
 */
68
@org.jreleaser.infra.nativeimage.annotations.NativeImage
69
@ServiceProviderFor(ExtensionManager.class)
70
public final class DefaultExtensionManager implements ExtensionManager {
1✔
71
    private final Map<String, ExtensionDef> extensionDefs = new LinkedHashMap<>();
1✔
72
    private final Set<ExtensionPoint> allExtensionPoints = new LinkedHashSet<>();
1✔
73
    private final Map<String, Set<ExtensionPoint>> extensionPoints = new LinkedHashMap<>();
1✔
74

75
    public ExtensionBuilder configureExtension(String name) {
76
        return new ExtensionBuilder(name, this);
×
77
    }
78

79
    public void load(JReleaserContext context) {
80
        extensionPoints.clear();
1✔
81
        allExtensionPoints.clear();
1✔
82

83
        Set<String> visitedExtensionNames = new LinkedHashSet<>();
1✔
84
        Set<String> visitedExtensionTypes = new LinkedHashSet<>();
1✔
85

86
        // load defaults
87
        for (Extension extension : resolveServiceLoader()) {
1✔
88
            processExtension(context, extension, visitedExtensionNames, visitedExtensionTypes);
1✔
89
        }
1✔
90

91
        for (Map.Entry<String, ExtensionDef> e : extensionDefs.entrySet()) {
1✔
92
            String extensionName = e.getKey();
×
93
            ExtensionDef extensionDef = e.getValue();
×
94
            if (visitedExtensionNames.contains(extensionName)) {
×
95
                continue;
×
96
            }
97

98
            if (!extensionDef.isEnabled()) {
×
99
                context.getLogger().debug(RB.$("extension.manager.disabled", extensionName));
×
100
                return;
×
101
            }
102

103
            createClassLoader(context, extensionDef).ifPresent(classLoader -> {
×
104
                for (Extension extension : ServiceLoader.load(Extension.class, classLoader)) {
×
105
                    processExtension(context, extension, visitedExtensionNames, visitedExtensionTypes);
×
106
                }
×
107
            });
×
108
        }
×
109

110
        context.setWorkflowListeners(findExtensionPoints(WorkflowListener.class));
1✔
111
    }
1✔
112

113
    @Override
114
    public <T extends ExtensionPoint> Set<T> findExtensionPoints(Class<T> extensionPointType) {
115
        return (Set<T>) extensionPoints.computeIfAbsent(extensionPointType.getName(), k -> {
1✔
116
            Set<T> set = new LinkedHashSet<>();
1✔
117

118
            for (ExtensionPoint extensionPoint : allExtensionPoints) {
1✔
119
                if (extensionPointType.isAssignableFrom(extensionPoint.getClass())) {
1✔
120
                    set.add((T) extensionPoint);
1✔
121
                }
122
            }
1✔
123

124
            return Collections.unmodifiableSet(set);
1✔
125
        });
126
    }
127

128
    private Optional<ClassLoader> createClassLoader(JReleaserContext context, ExtensionDef extensionDef) {
129
        String directory = extensionDef.getDirectory();
×
130

131
        if (isNotBlank(extensionDef.getGav())) {
×
132
            directory = resolveJARs(context, extensionDef);
×
133
        } 
134

NEW
135
        if (isNotBlank(extensionDef.getJbang())) {
×
NEW
136
            directory = resolveJBangJARs(context, extensionDef);
×
137
        }
138

139
        Path directoryPath = Paths.get(directory);
×
140
        if (!directoryPath.isAbsolute()) {
×
141
            directoryPath = context.getBasedir().resolve(directoryPath);
×
142
        }
143

144
        if (!Files.exists(directoryPath)) {
×
145
            context.getLogger().warn(RB.$("extension.manager.load.directory.missing", extensionDef.getName(), directoryPath.toAbsolutePath()));
×
146
            return Optional.empty();
×
147
        }
148

149
        List<Path> jars = null;
×
NEW
150
        try (Stream<Path> jarPaths = Files.walk(directoryPath)) {
×
151
            jars = jarPaths
×
152
                .filter(path -> path.getFileName().toString().endsWith(".jar"))
×
NEW
153
                .filter(path -> Files.isRegularFile(path))
×
154
                .collect(toList());
×
155
        } catch (IOException e) {
×
156
            context.getLogger().trace(e);
×
157
            context.getLogger().warn(RB.$("extension.manager.load.directory.error", extensionDef.getName(), directoryPath.toAbsolutePath()));
×
158
            return Optional.empty();
×
159
        }
×
160

161
        if (jars.isEmpty()) {
×
162
            context.getLogger().warn(RB.$("extension.manager.load.empty.jars", extensionDef.getName(), directoryPath.toAbsolutePath()));
×
163
            return Optional.empty();
×
164
        }
165

166
        URL[] urls = new URL[jars.size()];
×
167
        for (int i = 0; i < jars.size(); i++) {
×
168
            Path jar = jars.get(i);
×
169
            try {
170
                urls[i] = jar.toUri().toURL();
×
171
            } catch (MalformedURLException e) {
×
172
                context.getLogger().trace(e);
×
173
                context.getLogger().warn(RB.$("extension.manager.load.jar.error", extensionDef.getName(), jar.toAbsolutePath()));
×
174
                return Optional.empty();
×
175
            }
×
176
        }
177

178
        return Optional.of(new URLClassLoader(urls, getClass().getClassLoader()));
×
179
    }
180

181
    private String resolveJARs(JReleaserContext context, ExtensionDef extensionDef) {
182
        Path target = context.getOutputDirectory().resolve("extensions")
×
183
            .resolve(extensionDef.getName())
×
184
            .toAbsolutePath();
×
185

186
        Mvn mvn = new Mvn(context.asImmutable(), DefaultVersions.getInstance().getMvnVersion());
×
187

188
        try {
189
            if (!mvn.setup()) {
×
190
                throw new JReleaserException(RB.$("tool_unavailable", "mvn"));
×
191
            }
192
        } catch (ToolException e) {
×
193
            throw new JReleaserException(RB.$("tool_unavailable", "mvn"), e);
×
194
        }
×
195

196
        try {
197
            FileUtils.deleteFiles(target, true);
×
198

199
            Path pom = Files.createTempFile("jreleaser-extensions", "pom.xml");
×
200

201
            TemplateResource template = TemplateUtils.resolveTemplate(context.getLogger(), "extensions/pom.xml.tpl");
×
202

203
            String[] gav = extensionDef.getGav().split(":");
×
204

205
            String content = IOUtils.toString(template.getReader());
×
206
            content = content.replace("@groupId@", gav[0])
×
207
                .replace("@artifactId@", gav[1])
×
208
                .replace("@version@", gav[2]);
×
209

210
            Files.write(pom, content.getBytes(UTF_8), WRITE, TRUNCATE_EXISTING);
×
211

212
            List<String> args = new ArrayList<>();
×
213
            args.add("-B");
×
214
            args.add("-q");
×
215
            args.add("-f");
×
216
            args.add(pom.toAbsolutePath().toString());
×
217
            args.add("dependency:resolve");
×
218
            // resolve
219
            context.getLogger().debug(RB.$("extension.manager.resolve.jars", extensionDef.getGav()));
×
220
            mvn.invoke(context.getBasedir(), args);
×
221

222
            args.clear();
×
223
            args.add("-B");
×
224
            args.add("-q");
×
225
            args.add("-f");
×
226
            args.add(pom.toAbsolutePath().toString());
×
227
            args.add("dependency:copy-dependencies");
×
228
            args.add("-DoutputDirectory=" + target);
×
229
            // copy
230
            context.getLogger().debug(RB.$("extension.manager.copy.jars", extensionDef.getGav(), context.relativizeToBasedir(target)));
×
231
            mvn.invoke(context.getBasedir(), args);
×
232
        } catch (IOException | CommandException e) {
×
233
            throw new JReleaserException(RB.$("ERROR_unexpected_error"), e);
×
234
        }
×
235

236
        return target.toString();
×
237
    }
238

239
    private String resolveJBangJARs(JReleaserContext context, ExtensionDef extensionDef) {
NEW
240
        Path target = context.getOutputDirectory().resolve("extensions")
×
NEW
241
            .resolve(extensionDef.getName())
×
NEW
242
            .toAbsolutePath();
×
243

NEW
244
        JBang jbang = new JBang(context.asImmutable(), DefaultVersions.getInstance().getJbangVersion());
×
245

246
        try {
NEW
247
            if (!jbang.setup()) {
×
NEW
248
                throw new JReleaserException(RB.$("tool_unavailable", "jbang"));
×
249
            }
NEW
250
        } catch (ToolException e) {
×
NEW
251
            throw new JReleaserException(RB.$("tool_unavailable", "jbang"), e);
×
NEW
252
        }
×
253

254
        try {
NEW
255
            FileUtils.deleteFiles(target, true);
×
256

257
            // build and export the extension to a portable jar
258
            // <extname>.jar and lib/<deps>.jar
259
            // downside is that the lib dir will basically
260
            // have a copy of jreleaser deps.
261
            // once jbang supports buildTimeOnly deps it will be smaller.
NEW
262
            String targetJar = target.resolve(extensionDef.getName()).toString();
×
NEW
263
            List<String> args = new ArrayList<>();
×
NEW
264
            args.add("--quiet");
×
NEW
265
            args.add("export");
×
NEW
266
            args.add("portable");
×
NEW
267
            args.add("--force");
×
NEW
268
            args.add("-O");
×
NEW
269
            args.add(targetJar);
×
NEW
270
            args.add(extensionDef.getJbang());
×
271

NEW
272
            context.getLogger().debug(RB.$("extension.manager.jbang.export.jars", extensionDef.getGav(), context.relativizeToBasedir(targetJar)));
×
273

NEW
274
            jbang.invoke(context.getBasedir(), args);
×
NEW
275
        } catch (IOException | CommandException e) {
×
NEW
276
            throw new JReleaserException(RB.$("ERROR_unexpected_error"), e);
×
NEW
277
        }
×
278

NEW
279
        return target.toString();
×
280
    }
281

282
    private void processExtension(JReleaserContext context, Extension extension, Set<String> visitedExtensionNames, Set<String> visitedExtensionTypes) {
283
        String extensionName = extension.getName();
1✔
284
        String extensionType = extension.getClass().getName();
1✔
285

286
        if (visitedExtensionNames.contains(extensionName) || visitedExtensionTypes.contains(extensionType)) {
1✔
287
            return;
×
288
        }
289

290
        context.getLogger().debug(RB.$("extension.manager.load", extensionName, extensionType));
1✔
291
        visitedExtensionNames.add(extensionName);
1✔
292
        visitedExtensionTypes.add(extensionType);
1✔
293

294
        ExtensionDef extensionDef = extensionDefs.get(extensionName);
1✔
295

296
        if (null != extensionDef && !extensionDef.isEnabled()) {
1✔
297
            context.getLogger().debug(RB.$("extension.manager.disabled", extensionName));
×
298
            return;
×
299
        }
300

301
        for (ExtensionPoint extensionPoint : extension.provides()) {
1✔
302
            String extensionPointTypeName = extensionPoint.getClass().getName();
1✔
303
            if (null != extensionDef && extensionDef.getExtensionPoints().containsKey(extensionPointTypeName)) {
1✔
304
                extensionPoint.init(context.asImmutable(), extensionDef.getExtensionPoints().get(extensionPointTypeName)
×
305
                    .getProperties());
×
306
            } else {
307
                extensionPoint.init(context.asImmutable(), Collections.emptyMap());
1✔
308
            }
309
            context.getLogger().debug(RB.$("extension.manager.add.extension.point", extensionPointTypeName, extensionName));
1✔
310
            allExtensionPoints.add(extensionPoint);
1✔
311
        }
1✔
312
    }
1✔
313

314
    private static ServiceLoader<Extension> resolveServiceLoader() {
315
        // Check if the type.classLoader works
316
        ServiceLoader<Extension> handlers = ServiceLoader.load(Extension.class, Extension.class.getClassLoader());
1✔
317
        if (handlers.iterator().hasNext()) {
1✔
318
            return handlers;
1✔
319
        }
320

321
        // If *nothing* else works
322
        return ServiceLoader.load(Extension.class);
×
323
    }
324

325
    private static class ExtensionDef {
326
        private final String name;
327
        private final String gav;
328
        private final String directory;
329
        private final String jbang;
330
        private final boolean enabled;
331
        private final Map<String, ExtensionPointDef> extensionPoints = new LinkedHashMap<>();
×
332

NEW
333
        private ExtensionDef(String name, String directory, String gav, String jbang, boolean enabled, Map<String, ExtensionPointDef> extensionPoints) {
×
334
            this.name = name;
×
335
            this.gav = gav;
×
336
            this.directory = directory;
×
NEW
337
            this.jbang = jbang;
×
338
            this.enabled = enabled;
×
339
            this.extensionPoints.putAll(extensionPoints);
×
340
        }
×
341

342
        private String getName() {
343
            return name;
×
344
        }
345

346
        public String getGav() {
347
            return gav;
×
348
        }
349

350
        private String getDirectory() {
351
            return directory;
×
352
        }
353

354
        public String getJbang() {
NEW
355
            return jbang;
×
356
        }
357
        private boolean isEnabled() {
358
            return enabled;
×
359
        }
360

361
        private Map<String, ExtensionPointDef> getExtensionPoints() {
362
            return extensionPoints;
×
363
        }
364
    }
365

366
    private static class ExtensionPointDef {
367
        private final String type;
368
        private final Map<String, Object> properties = new LinkedHashMap<>();
×
369

370
        private ExtensionPointDef(String type, Map<String, Object> properties) {
×
371
            this.type = type;
×
372
            this.properties.putAll(properties);
×
373
        }
×
374

375
        private String getType() {
376
            return type;
×
377
        }
378

379
        private Map<String, Object> getProperties() {
380
            return properties;
×
381
        }
382
    }
383

384
    public static class ExtensionBuilder {
385
        private final Map<String, ExtensionPointDef> extensionPoints = new LinkedHashMap<>();
×
386
        private final String name;
387
        private final DefaultExtensionManager defaultExtensionManager;
388
        private String gav;
389
        private String directory;
390
        private String jbang;
391
        private boolean enabled;
392

393
        public ExtensionBuilder(String name, DefaultExtensionManager defaultExtensionManager) {
×
394
            this.name = name;
×
395
            this.defaultExtensionManager = defaultExtensionManager;
×
396

397
            String jreleaserHome = System.getenv("JRELEASER_USER_HOME");
×
398
            if (isBlank(jreleaserHome)) {
×
399
                jreleaserHome = System.getProperty("user.home") + File.separator + ".jreleaser";
×
400
            }
401
            Path baseExtensionsDirectory = Paths.get(jreleaserHome).resolve("extensions");
×
402
            this.directory = baseExtensionsDirectory.resolve(name).toAbsolutePath().toString();
×
403
        }
×
404

405
        public ExtensionBuilder withGav(String gav) {
406
            this.gav = gav;
×
407
            return this;
×
408
        }
409

410
        public ExtensionBuilder withDirectory(String directory) {
411
            this.directory = directory;
×
412
            return this;
×
413
        }
414

415
        public ExtensionBuilder withJBang(String jbang) {
NEW
416
            this.jbang = jbang;
×
NEW
417
            return this;
×
418
        }
419

420
        public ExtensionBuilder withEnabled(boolean enabled) {
421
            this.enabled = enabled;
×
422
            return this;
×
423
        }
424

425
        public ExtensionBuilder withExtensionPoint(String type, Map<String, Object> properties) {
426
            extensionPoints.put(type, new ExtensionPointDef(type, properties));
×
427
            return this;
×
428
        }
429

430
        public void build() {
431
            defaultExtensionManager.extensionDefs.put(name,
×
432
                new ExtensionDef(name, directory, gav, jbang, enabled, extensionPoints));
433
        }
×
434
    }
435
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc