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

jreleaser / jreleaser / #522

29 Jul 2025 09:15PM UTC coverage: 45.397% (-0.07%) from 45.469%
#522

push

github

aalmiray
feat(extensions): Refine JBang extension support

1 of 115 new or added lines in 9 files covered. (0.87%)

4 existing lines in 2 files now uncovered.

24047 of 52970 relevant lines covered (45.4%)

0.45 hits per line

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

19.32
/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.model.internal.tools.Jbang;
29
import org.jreleaser.sdk.command.CommandException;
30
import org.jreleaser.sdk.tool.JBang;
31
import org.jreleaser.sdk.tool.Mvn;
32
import org.jreleaser.sdk.tool.ToolException;
33
import org.jreleaser.templates.TemplateResource;
34
import org.jreleaser.templates.TemplateUtils;
35
import org.jreleaser.util.DefaultVersions;
36
import org.jreleaser.util.FileUtils;
37
import org.kordamp.jipsy.annotations.ServiceProviderFor;
38

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

NEW
245
        String jbangVersion = DefaultVersions.getInstance().getJbangVersion();
×
NEW
246
        if (isNotBlank(extensionDef.getJbang().getVersion())) {
×
NEW
247
            jbangVersion = extensionDef.getJbang().getVersion();
×
248
        }
NEW
249
        JBang jbang = new JBang(context.asImmutable(), jbangVersion);
×
250

251
        try {
252
            if (!jbang.setup()) {
×
253
                throw new JReleaserException(RB.$("tool_unavailable", "jbang"));
×
254
            }
255
        } catch (ToolException e) {
×
256
            throw new JReleaserException(RB.$("tool_unavailable", "jbang"), e);
×
257
        }
×
258

259
        try {
260
            FileUtils.deleteFiles(target, true);
×
261

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

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

279
            jbang.invoke(context.getBasedir(), args);
×
280
        } catch (IOException | CommandException e) {
×
281
            throw new JReleaserException(RB.$("ERROR_unexpected_error"), e);
×
282
        }
×
283

284
        return target.toString();
×
285
    }
286

287
    private void processExtension(JReleaserContext context, Extension extension, Set<String> visitedExtensionNames, Set<String> visitedExtensionTypes) {
288
        String extensionName = extension.getName();
1✔
289
        String extensionType = extension.getClass().getName();
1✔
290

291
        if (visitedExtensionNames.contains(extensionName) || visitedExtensionTypes.contains(extensionType)) {
1✔
292
            return;
×
293
        }
294

295
        context.getLogger().debug(RB.$("extension.manager.load", extensionName, extensionType));
1✔
296
        visitedExtensionNames.add(extensionName);
1✔
297
        visitedExtensionTypes.add(extensionType);
1✔
298

299
        ExtensionDef extensionDef = extensionDefs.get(extensionName);
1✔
300

301
        if (null != extensionDef && !extensionDef.isEnabled()) {
1✔
302
            context.getLogger().debug(RB.$("extension.manager.disabled", extensionName));
×
303
            return;
×
304
        }
305

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

319
    private static ServiceLoader<Extension> resolveServiceLoader() {
320
        // Check if the type.classLoader works
321
        ServiceLoader<Extension> handlers = ServiceLoader.load(Extension.class, Extension.class.getClassLoader());
1✔
322
        if (handlers.iterator().hasNext()) {
1✔
323
            return handlers;
1✔
324
        }
325

326
        // If *nothing* else works
327
        return ServiceLoader.load(Extension.class);
×
328
    }
329

330
    private static class ExtensionDef {
331
        private final String name;
332
        private final String gav;
333
        private final String directory;
NEW
334
        private final Jbang jbang = new Jbang();
×
335
        private final boolean enabled;
336
        private final Map<String, ExtensionPointDef> extensionPoints = new LinkedHashMap<>();
×
337

NEW
338
        private ExtensionDef(String name, String directory, String gav, Jbang jbang, boolean enabled, Map<String, ExtensionPointDef> extensionPoints) {
×
339
            this.name = name;
×
340
            this.gav = gav;
×
341
            this.directory = directory;
×
NEW
342
            this.jbang.merge(jbang);
×
343
            this.enabled = enabled;
×
344
            this.extensionPoints.putAll(extensionPoints);
×
345
        }
×
346

347
        private String getName() {
348
            return name;
×
349
        }
350

351
        public String getGav() {
352
            return gav;
×
353
        }
354

355
        private String getDirectory() {
356
            return directory;
×
357
        }
358

359
        public Jbang getJbang() {
360
            return jbang;
×
361
        }
362

363
        private boolean isEnabled() {
364
            return enabled;
×
365
        }
366

367
        private Map<String, ExtensionPointDef> getExtensionPoints() {
368
            return extensionPoints;
×
369
        }
370
    }
371

372
    private static class ExtensionPointDef {
373
        private final String type;
374
        private final Map<String, Object> properties = new LinkedHashMap<>();
×
375

376
        private ExtensionPointDef(String type, Map<String, Object> properties) {
×
377
            this.type = type;
×
378
            this.properties.putAll(properties);
×
379
        }
×
380

381
        private String getType() {
382
            return type;
×
383
        }
384

385
        private Map<String, Object> getProperties() {
386
            return properties;
×
387
        }
388
    }
389

390
    public static class ExtensionBuilder {
391
        private final Map<String, ExtensionPointDef> extensionPoints = new LinkedHashMap<>();
×
392
        private final String name;
393
        private final DefaultExtensionManager defaultExtensionManager;
394
        private String gav;
395
        private String directory;
396
        private Jbang jbang;
397
        private boolean enabled;
398

399
        public ExtensionBuilder(String name, DefaultExtensionManager defaultExtensionManager) {
×
400
            this.name = name;
×
401
            this.defaultExtensionManager = defaultExtensionManager;
×
402

403
            String jreleaserHome = System.getenv("JRELEASER_USER_HOME");
×
404
            if (isBlank(jreleaserHome)) {
×
405
                jreleaserHome = System.getProperty("user.home") + File.separator + ".jreleaser";
×
406
            }
407
            Path baseExtensionsDirectory = Paths.get(jreleaserHome).resolve("extensions");
×
408
            this.directory = baseExtensionsDirectory.resolve(name).toAbsolutePath().toString();
×
409
        }
×
410

411
        public ExtensionBuilder withGav(String gav) {
412
            this.gav = gav;
×
413
            return this;
×
414
        }
415

416
        public ExtensionBuilder withDirectory(String directory) {
417
            this.directory = directory;
×
418
            return this;
×
419
        }
420

421
        public ExtensionBuilder withJBang(Jbang jbang) {
422
            this.jbang = jbang;
×
423
            return this;
×
424
        }
425

426
        public ExtensionBuilder withEnabled(boolean enabled) {
427
            this.enabled = enabled;
×
428
            return this;
×
429
        }
430

431
        public ExtensionBuilder withExtensionPoint(String type, Map<String, Object> properties) {
432
            extensionPoints.put(type, new ExtensionPointDef(type, properties));
×
433
            return this;
×
434
        }
435

436
        public void build() {
437
            defaultExtensionManager.extensionDefs.put(name,
×
438
                new ExtensionDef(name, directory, gav, jbang, enabled, extensionPoints));
439
        }
×
440
    }
441
}
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