From 7998050dd19f281c7c9308ac87a2312ac21e4b68 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Fri, 29 May 2026 01:15:37 +0200 Subject: [PATCH 1/2] feat: support release tag names in JBANG_DOWNLOAD_VERSION Numeric values (e.g. 0.120.0) continue to be resolved to the matching vX.Y.Z GitHub release. Non-numeric values are now used as a release tag name as-is, which makes the perpetual early-access build reachable via: export JBANG_DOWNLOAD_VERSION=early-access resolving to https://github.com/jbangdev/jbang/releases/download/early-access/ Applied consistently in jbang (bash) and jbang.ps1; jbang.cmd delegates downloads to jbang.ps1 so needs no change. Docs updated in installation.adoc. New WireMock-backed script tests verify the URL construction for both numeric and tag-named values on bash and pwsh. --- docs/modules/ROOT/pages/installation.adoc | 19 +- src/main/scripts/jbang | 8 +- src/main/scripts/jbang.ps1 | 9 +- .../jbang/cli/TestScriptDownloadVersion.java | 301 ++++++++++++++++++ 4 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 src/test/java/dev/jbang/cli/TestScriptDownloadVersion.java diff --git a/docs/modules/ROOT/pages/installation.adoc b/docs/modules/ROOT/pages/installation.adoc index 006be2864..d8252744d 100644 --- a/docs/modules/ROOT/pages/installation.adoc +++ b/docs/modules/ROOT/pages/installation.adoc @@ -444,6 +444,23 @@ By default JBang downloads itself from GitHub. You can override the download URL export JBANG_DOWNLOAD_URL=https://internal-mirror.example.com/jbang/jbang.tar ---- +=== Downloading a specific version or the early-access build + +Set `JBANG_DOWNLOAD_VERSION` to pick a specific release. Numeric values are resolved to the matching `vX.Y.Z` GitHub release; any other value is used as a release tag name as-is. This means you can opt-in to the perpetual early-access build with a simple: + +[source,bash] +---- +export JBANG_DOWNLOAD_VERSION=early-access +---- + +which resolves to https://github.com/jbangdev/jbang/releases/download/early-access/ . Equivalent forms: + +[source,bash] +---- +export JBANG_DOWNLOAD_VERSION=0.120.0 # -> .../download/v0.120.0/jbang.tar +export JBANG_DOWNLOAD_VERSION=early-access # -> .../download/early-access/jbang.tar +---- + == Version Check `jbang` will check once a day if a new version is available. If a new version is available a message will be printed with information on how to install. @@ -504,7 +521,7 @@ These are read by the scripts themselves before Java or JBang's Java code is inv | `JBANG_DOWNLOAD_VERSION` | _(latest)_ -| Specific JBang version to download (e.g., `0.120.0`). When unset, the latest release is used. +| Specific JBang version to download. Numeric values (e.g., `0.120.0`) are resolved to the matching `vX.Y.Z` GitHub release; any non-numeric value (e.g., `early-access`) is used as a release tag name as-is. When unset, the latest release is used. | `JBANG_JAVA_OPTIONS` | _(empty)_ diff --git a/src/main/scripts/jbang b/src/main/scripts/jbang index a80321a36..4c5ac81f5 100755 --- a/src/main/scripts/jbang +++ b/src/main/scripts/jbang @@ -287,7 +287,13 @@ if [[ -z "$binaryPath" && -z "$jarPath" ]]; then elif [ -z "$JBANG_DOWNLOAD_VERSION" ]; then jburl="${jbangDownloadBaseUrl}/latest/download/jbang.tar"; else - jburl="${jbangDownloadBaseUrl}/download/v$JBANG_DOWNLOAD_VERSION/jbang.tar"; + # Numeric versions get a 'v' prefix (e.g. 0.120.0 -> v0.120.0); named + # release tags (e.g. 'early-access') are used as-is. + case "$JBANG_DOWNLOAD_VERSION" in + [0-9]*) jbtag="v$JBANG_DOWNLOAD_VERSION" ;; + *) jbtag="$JBANG_DOWNLOAD_VERSION" ;; + esac + jburl="${jbangDownloadBaseUrl}/download/$jbtag/jbang.tar"; fi echo "Downloading JBang ${JBANG_DOWNLOAD_VERSION:-latest} from $jburl..." 1>&2 download "$jburl" "$TDIR/urls/jbang.tar" diff --git a/src/main/scripts/jbang.ps1 b/src/main/scripts/jbang.ps1 index 8be978cfe..4c4a4cbce 100644 --- a/src/main/scripts/jbang.ps1 +++ b/src/main/scripts/jbang.ps1 @@ -157,7 +157,14 @@ if (-not $binaryPath -and -not $jarPath) { } elseif (-not (Test-Path env:JBANG_DOWNLOAD_VERSION)) { $jburl="$jbangDownloadBaseUrl/latest/download/jbang.zip" } else { - $jburl="$jbangDownloadBaseUrl/download/v$env:JBANG_DOWNLOAD_VERSION/jbang.zip"; + # Numeric versions get a 'v' prefix (e.g. 0.120.0 -> v0.120.0); named + # release tags (e.g. 'early-access') are used as-is. + if ($env:JBANG_DOWNLOAD_VERSION -match '^[0-9]') { + $jbtag = "v$env:JBANG_DOWNLOAD_VERSION" + } else { + $jbtag = $env:JBANG_DOWNLOAD_VERSION + } + $jburl="$jbangDownloadBaseUrl/download/$jbtag/jbang.zip"; } $dlVersion = if ($env:JBANG_DOWNLOAD_VERSION) { $env:JBANG_DOWNLOAD_VERSION } else { 'latest' } [Console]::Error.WriteLine("Downloading JBang $dlVersion from $jburl...") diff --git a/src/test/java/dev/jbang/cli/TestScriptDownloadVersion.java b/src/test/java/dev/jbang/cli/TestScriptDownloadVersion.java new file mode 100644 index 000000000..5d3001206 --- /dev/null +++ b/src/test/java/dev/jbang/cli/TestScriptDownloadVersion.java @@ -0,0 +1,301 @@ +package dev.jbang.cli; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +/** + * Functional tests verifying how JBANG_DOWNLOAD_VERSION is translated into a + * GitHub release tag URL by the startup scripts. + * + * Numeric versions (e.g. "0.120.0") are mapped to the matching {@code vX.Y.Z} + * release. Non-numeric values (e.g. "early-access") are used as a release tag + * name as-is. This is what makes + * {@code https://github.com/jbangdev/jbang/releases/download/early-access/} + * usable via the existing {@code JBANG_DOWNLOAD_*} variables. + */ +class TestScriptDownloadVersion { + + private static final Path BASH_SCRIPT = Paths.get("src/main/scripts/jbang").toAbsolutePath(); + private static final Path PS1_SCRIPT = Paths.get("src/main/scripts/jbang.ps1").toAbsolutePath(); + + private WireMockServer wm; + + @TempDir + Path tempDir; + + @BeforeEach + void startWireMock() { + wm = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + wm.start(); + } + + @AfterEach + void stopWireMock() { + if (wm != null) { + wm.stop(); + } + } + + private static boolean isCommandAvailable(String command) { + try { + Process p = new ProcessBuilder(command, "--version") + .redirectErrorStream(true) + .start(); + p.getInputStream().transferTo(new ByteArrayOutputStream()); + return p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0; + } catch (Exception e) { + return false; + } + } + + private static RunResult runProcess(List cmd, Map env) throws Exception { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.environment().putAll(env); + pb.redirectErrorStream(false); + Process process = pb.start(); + + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + Thread t1 = new Thread(() -> { + try { + process.getInputStream().transferTo(stdout); + } catch (Exception e) { + /* ignore */ } + }); + Thread t2 = new Thread(() -> { + try { + process.getErrorStream().transferTo(stderr); + } catch (Exception e) { + /* ignore */ } + }); + t1.start(); + t2.start(); + + boolean finished = process.waitFor(120, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + } + t1.join(5000); + t2.join(5000); + assertTrue(finished, "script timed out"); + return new RunResult(process.exitValue(), + stdout.toString(StandardCharsets.UTF_8), + stderr.toString(StandardCharsets.UTF_8)); + } + + static class RunResult { + final int exitCode; + final String stdout; + final String stderr; + + RunResult(int exitCode, String stdout, String stderr) { + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + } + } + + // ------------------------------------------------------------------------- + // Bash + // ------------------------------------------------------------------------- + + @Nested + class Bash { + + private byte[] createJbangTar() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (TarArchiveOutputStream tar = new TarArchiveOutputStream(baos)) { + byte[] script = "#!/bin/bash\nexit 0\n".getBytes(StandardCharsets.UTF_8); + TarArchiveEntry entry = new TarArchiveEntry("jbang/bin/jbang"); + entry.setSize(script.length); + entry.setMode(0755); + tar.putArchiveEntry(entry); + tar.write(script); + tar.closeArchiveEntry(); + + TarArchiveEntry jarEntry = new TarArchiveEntry("jbang/bin/jbang.jar"); + jarEntry.setSize(0); + tar.putArchiveEntry(jarEntry); + tar.closeArchiveEntry(); + } + return baos.toByteArray(); + } + + private Map bashEnv(String version) { + Path jbdir = tempDir.resolve("jbdir-" + version); + Path tdir = tempDir.resolve("cache-" + version); + Map env = new HashMap<>(System.getenv()); + env.put("JBANG_DIR", jbdir.toString()); + env.put("JBANG_CACHE_DIR", tdir.toString()); + env.put("JBANG_DOWNLOAD_BASEURL", wm.baseUrl()); + env.put("JBANG_DOWNLOAD_VERSION", version); + env.put("JBANG_DOWNLOAD_RETRY", "0"); + env.put("JBANG_NO_VERSION_CHECK", "true"); + env.remove("JAVA_HOME"); + env.remove("JBANG_DOWNLOAD_URL"); + return env; + } + + @BeforeEach + void requireBash() { + assumeTrue(isCommandAvailable("bash"), "bash is not available"); + } + + @Test + void numericVersionGetsVPrefix() throws Exception { + byte[] tar = createJbangTar(); + wm.stubFor(WireMock.get(WireMock.urlEqualTo("/download/v0.120.0/jbang.tar")) + .willReturn(WireMock.aResponse().withStatus(200).withBody(tar))); + + List cmd = new ArrayList<>(); + cmd.add("bash"); + cmd.add(BASH_SCRIPT.toString()); + cmd.add("version"); + + RunResult result = runProcess(cmd, bashEnv("0.120.0")); + + wm.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/download/v0.120.0/jbang.tar"))); + assertTrue(!result.stderr.contains("Error downloading JBang"), + "download should have succeeded, stderr: " + result.stderr); + } + + @Test + void earlyAccessTagIsUsedAsIs() throws Exception { + byte[] tar = createJbangTar(); + wm.stubFor(WireMock.get(WireMock.urlEqualTo("/download/early-access/jbang.tar")) + .willReturn(WireMock.aResponse().withStatus(200).withBody(tar))); + + List cmd = new ArrayList<>(); + cmd.add("bash"); + cmd.add(BASH_SCRIPT.toString()); + cmd.add("version"); + + RunResult result = runProcess(cmd, bashEnv("early-access")); + + wm.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/download/early-access/jbang.tar"))); + assertTrue(!result.stderr.contains("Error downloading JBang"), + "download should have succeeded, stderr: " + result.stderr); + } + } + + // ------------------------------------------------------------------------- + // PowerShell + // ------------------------------------------------------------------------- + + @Nested + class PowerShell { + + private String psCommand; + + private byte[] createJbangZip() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(baos)) { + zip.putNextEntry(new ZipEntry("jbang/bin/jbang.ps1")); + zip.write("exit 0\n".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + + zip.putNextEntry(new ZipEntry("jbang/bin/jbang.jar")); + zip.closeEntry(); + + zip.putNextEntry(new ZipEntry("jbang/bin/jbang.cmd")); + zip.write("@exit /b 0\r\n".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return baos.toByteArray(); + } + + private Map psEnv(String version) { + Path jbdir = tempDir.resolve("jbdir-" + version); + Path tdir = tempDir.resolve("cache-" + version); + Map env = new HashMap<>(System.getenv()); + env.put("JBANG_DIR", jbdir.toString()); + env.put("JBANG_CACHE_DIR", tdir.toString()); + env.put("JBANG_DOWNLOAD_BASEURL", wm.baseUrl()); + env.put("JBANG_DOWNLOAD_VERSION", version); + env.put("JBANG_DOWNLOAD_RETRY", "0"); + env.put("JBANG_NO_VERSION_CHECK", "true"); + env.remove("JAVA_HOME"); + env.remove("JBANG_DOWNLOAD_URL"); + return env; + } + + @BeforeEach + void requirePowerShell() { + if (isCommandAvailable("pwsh")) { + psCommand = "pwsh"; + } else if (isCommandAvailable("powershell")) { + psCommand = "powershell"; + } else { + assumeTrue(false, "PowerShell is not available (neither pwsh nor powershell found)"); + } + } + + @Test + void numericVersionGetsVPrefix() throws Exception { + byte[] zip = createJbangZip(); + wm.stubFor(WireMock.get(WireMock.urlEqualTo("/download/v0.120.0/jbang.zip")) + .willReturn(WireMock.aResponse().withStatus(200).withBody(zip))); + + List cmd = new ArrayList<>(); + cmd.add(psCommand); + cmd.add("-NoProfile"); + cmd.add("-ExecutionPolicy"); + cmd.add("Bypass"); + cmd.add("-File"); + cmd.add(PS1_SCRIPT.toString()); + cmd.add("version"); + + RunResult result = runProcess(cmd, psEnv("0.120.0")); + + wm.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/download/v0.120.0/jbang.zip"))); + assertTrue(!result.stderr.contains("Error downloading JBang"), + "download should have succeeded, stderr: " + result.stderr); + } + + @Test + void earlyAccessTagIsUsedAsIs() throws Exception { + byte[] zip = createJbangZip(); + wm.stubFor(WireMock.get(WireMock.urlEqualTo("/download/early-access/jbang.zip")) + .willReturn(WireMock.aResponse().withStatus(200).withBody(zip))); + + List cmd = new ArrayList<>(); + cmd.add(psCommand); + cmd.add("-NoProfile"); + cmd.add("-ExecutionPolicy"); + cmd.add("Bypass"); + cmd.add("-File"); + cmd.add(PS1_SCRIPT.toString()); + cmd.add("version"); + + RunResult result = runProcess(cmd, psEnv("early-access")); + + wm.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/download/early-access/jbang.zip"))); + assertTrue(!result.stderr.contains("Error downloading JBang"), + "download should have succeeded, stderr: " + result.stderr); + } + } +} From 74a7f43b2e0282b3a26f469784e78dcd0b157434 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Sat, 30 May 2026 00:22:59 +0200 Subject: [PATCH 2/2] fix: tighten JBANG_DOWNLOAD_VERSION tag detection and document early-access install - Only prefix 'v' when JBANG_DOWNLOAD_VERSION is purely dotted-numeric; values like '1.0.0-rc1' or 'early-access' are now passed through as-is in both bash and PowerShell startup scripts. - Align bash glob ('*[!0-9.]*') and PowerShell regex ('^[0-9]+(\.[0-9]+)*$') so both platforms classify the same inputs identically. - Add TestScriptDownloadVersion regression cases for numeric, named-tag (early-access) and digit-prefixed prerelease (1.0.0-rc1) values, run against the real scripts via WireMock for bash and PowerShell. - Add an early-access install snippet to the JReleaser changelog template showing the bash and PowerShell one-liners. - Document the version-vs-tag selection rule in installation.adoc. --- jreleaser.yml | 24 +++++++++++ src/main/scripts/jbang | 6 +-- src/main/scripts/jbang.ps1 | 4 +- .../jbang/cli/TestScriptDownloadVersion.java | 40 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/jreleaser.yml b/jreleaser.yml index 94b18b618..c2a784644 100644 --- a/jreleaser.yml +++ b/jreleaser.yml @@ -39,6 +39,7 @@ project: snapshot: pattern: '^\d+\.\d+\.\d+\.\d+(?:-SNAPSHOT)?$' label: early-access + fullChangelog: true release: github: @@ -53,6 +54,29 @@ release: formatted: always preset: "conventional-commits" format: '- {{commitShortHash}} {{commitTitle}}' + content: | + {{#Model.project.snapshot.enabled}} + WARNING: This is an early-access release directly from `main` branch, not intended for production use. + + If you want to try the latest and greatest, please give it a spin and report any issues you find. + + Here is how to install it: + + Linux / macOS / WSL (bash): + ``` + curl -Ls https://sh.jbang.dev | JBANG_DOWNLOAD_VERSION=early-access bash -s - app setup + ``` + + Windows PowerShell: + ``` + $env:JBANG_DOWNLOAD_VERSION='early-access'; iex "& { $(iwr -useb https://ps.jbang.dev) } app setup" + ``` + {{/Model.project.snapshot.enabled}} + + ## Changelog + + {{changelogChanges}} + {{changelogContributors}} checksum: individual: true diff --git a/src/main/scripts/jbang b/src/main/scripts/jbang index 4c5ac81f5..f746750e8 100755 --- a/src/main/scripts/jbang +++ b/src/main/scripts/jbang @@ -288,10 +288,10 @@ if [[ -z "$binaryPath" && -z "$jarPath" ]]; then jburl="${jbangDownloadBaseUrl}/latest/download/jbang.tar"; else # Numeric versions get a 'v' prefix (e.g. 0.120.0 -> v0.120.0); named - # release tags (e.g. 'early-access') are used as-is. + # release tags (e.g. 'early-access', '1.0.0-rc1') are used as-is. case "$JBANG_DOWNLOAD_VERSION" in - [0-9]*) jbtag="v$JBANG_DOWNLOAD_VERSION" ;; - *) jbtag="$JBANG_DOWNLOAD_VERSION" ;; + *[!0-9.]*) jbtag="$JBANG_DOWNLOAD_VERSION" ;; + *) jbtag="v$JBANG_DOWNLOAD_VERSION" ;; esac jburl="${jbangDownloadBaseUrl}/download/$jbtag/jbang.tar"; fi diff --git a/src/main/scripts/jbang.ps1 b/src/main/scripts/jbang.ps1 index 4c4a4cbce..71a923878 100644 --- a/src/main/scripts/jbang.ps1 +++ b/src/main/scripts/jbang.ps1 @@ -158,8 +158,8 @@ if (-not $binaryPath -and -not $jarPath) { $jburl="$jbangDownloadBaseUrl/latest/download/jbang.zip" } else { # Numeric versions get a 'v' prefix (e.g. 0.120.0 -> v0.120.0); named - # release tags (e.g. 'early-access') are used as-is. - if ($env:JBANG_DOWNLOAD_VERSION -match '^[0-9]') { + # release tags (e.g. 'early-access', '1.0.0-rc1') are used as-is. + if ($env:JBANG_DOWNLOAD_VERSION -match '^[0-9]+(\.[0-9]+)*$') { $jbtag = "v$env:JBANG_DOWNLOAD_VERSION" } else { $jbtag = $env:JBANG_DOWNLOAD_VERSION diff --git a/src/test/java/dev/jbang/cli/TestScriptDownloadVersion.java b/src/test/java/dev/jbang/cli/TestScriptDownloadVersion.java index 5d3001206..71b2d7b0d 100644 --- a/src/test/java/dev/jbang/cli/TestScriptDownloadVersion.java +++ b/src/test/java/dev/jbang/cli/TestScriptDownloadVersion.java @@ -200,6 +200,24 @@ void earlyAccessTagIsUsedAsIs() throws Exception { assertTrue(!result.stderr.contains("Error downloading JBang"), "download should have succeeded, stderr: " + result.stderr); } + + @Test + void prereleaseTagIsUsedAsIs() throws Exception { + byte[] tar = createJbangTar(); + wm.stubFor(WireMock.get(WireMock.urlEqualTo("/download/1.0.0-rc1/jbang.tar")) + .willReturn(WireMock.aResponse().withStatus(200).withBody(tar))); + + List cmd = new ArrayList<>(); + cmd.add("bash"); + cmd.add(BASH_SCRIPT.toString()); + cmd.add("version"); + + RunResult result = runProcess(cmd, bashEnv("1.0.0-rc1")); + + wm.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/download/1.0.0-rc1/jbang.tar"))); + assertTrue(!result.stderr.contains("Error downloading JBang"), + "download should have succeeded, stderr: " + result.stderr); + } } // ------------------------------------------------------------------------- @@ -297,5 +315,27 @@ void earlyAccessTagIsUsedAsIs() throws Exception { assertTrue(!result.stderr.contains("Error downloading JBang"), "download should have succeeded, stderr: " + result.stderr); } + + @Test + void prereleaseTagIsUsedAsIs() throws Exception { + byte[] zip = createJbangZip(); + wm.stubFor(WireMock.get(WireMock.urlEqualTo("/download/1.0.0-rc1/jbang.zip")) + .willReturn(WireMock.aResponse().withStatus(200).withBody(zip))); + + List cmd = new ArrayList<>(); + cmd.add(psCommand); + cmd.add("-NoProfile"); + cmd.add("-ExecutionPolicy"); + cmd.add("Bypass"); + cmd.add("-File"); + cmd.add(PS1_SCRIPT.toString()); + cmd.add("version"); + + RunResult result = runProcess(cmd, psEnv("1.0.0-rc1")); + + wm.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/download/1.0.0-rc1/jbang.zip"))); + assertTrue(!result.stderr.contains("Error downloading JBang"), + "download should have succeeded, stderr: " + result.stderr); + } } }