diff --git a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java
index 2f683713..e5e7bd93 100644
--- a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java
+++ b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java
@@ -20,10 +20,13 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
-import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Logger;
+import static org.jfrog.build.client.DownloadResponse.SHA256_HEADER_NAME;
+
/**
* Installer for JFrog CLI binary.
*
@@ -32,13 +35,32 @@
public abstract class BinaryInstaller extends ToolInstaller {
private static final Logger LOGGER = Logger.getLogger(BinaryInstaller.class.getName());
-
+
+ /**
+ * Environment variable that overrides the default lock-acquisition timeout (in minutes).
+ * Set this on the Jenkins controller when operating on slow networks where CLI downloads
+ * take longer than the default.
+ *
+ *
+ * export JFROG_CLI_INSTALL_TIMEOUT_MINUTES=15
+ *
+ */
+ static final String INSTALL_TIMEOUT_ENV_VAR = "JFROG_CLI_INSTALL_TIMEOUT_MINUTES";
+ private static final int DEFAULT_INSTALL_TIMEOUT_MINUTES = 5;
+
/**
* Per-node synchronization locks for installation coordination.
- * Key: Node name + tool installation path
- * Value: Object used as synchronization lock
+ * Key: installation path + binary name (see {@link #createLockKey})
+ * Value: ReentrantLock used to serialize installations to the same path
+ *
+ * The map grows by one entry per unique (agent path, binary) combination encountered
+ * during the lifetime of the Jenkins JVM. In typical deployments the number of distinct
+ * tool installation paths is small and bounded, so the unbounded growth is acceptable.
+ * If the entry count ever becomes a concern, entries can be evicted after a successful
+ * installation without loss of correctness (a new lock will be created on the
+ * next access).
*/
- private static final ConcurrentHashMap NODE_INSTALLATION_LOCKS = new ConcurrentHashMap<>();
+ private static final ConcurrentHashMap NODE_INSTALLATION_LOCKS = new ConcurrentHashMap<>();
protected BinaryInstaller(String label) {
super(label);
@@ -70,86 +92,132 @@ public String getId() {
}
/**
- * Performs JFrog CLI installation with proper synchronization to ensure reliable installation.
- *
+ * Performs JFrog CLI installation ensuring the pipeline always has a working binary.
+ *
* INSTALLATION STRATEGY:
- * 1. Create unique lock key per node + installation path
- * 2. Use synchronized block to ensure only one installation per location at a time
- * 3. Check if CLI already exists and is valid before downloading
- * 4. Download using atomic file operations for reliability
- *
+ * 1. Fast path (no lock): binary exists and sha256 check passes — return immediately.
+ * When the server does not return a sha256 header, the check treats "no hash" as
+ * "up-to-date", so a missing or empty sha256 file never causes a re-download loop.
+ * 2. Slow path (lock + download): acquire a ReentrantLock (configurable timeout, default
+ * 5 min) and call the downloader. Re-check version inside the lock in case a
+ * concurrent stage just finished.
+ * 3. Fallbacks: if the lock times out or the download fails, use any existing valid binary
+ * rather than failing the pipeline. Only throw when there is truly nothing to run.
+ *
* @param toolLocation Target directory for CLI installation
* @param log Task listener for logging progress
- * @param version CLI version to install
+ * @param version CLI version to install (blank = latest)
* @param instance JFrog platform instance for download
* @param repository Repository containing the CLI binary
* @param binaryName Name of the CLI binary file
* @return FilePath of the installed CLI
- * @throws IOException If installation fails
+ * @throws IOException If installation fails and no existing binary is available
* @throws InterruptedException If installation is interrupted
*/
- public static FilePath performJfrogCliInstallation(FilePath toolLocation, TaskListener log, String version,
- JFrogPlatformInstance instance, String repository, String binaryName)
+ public static FilePath performJfrogCliInstallation(FilePath toolLocation, TaskListener log, String version,
+ JFrogPlatformInstance instance, String repository, String binaryName)
throws IOException, InterruptedException {
-
- // Create unique lock key for this node + installation path + version combination
- String lockKey = createLockKey(toolLocation, binaryName, version);
-
- // Get or create synchronization lock for this specific installation location
- Object installationLock = NODE_INSTALLATION_LOCKS.computeIfAbsent(lockKey, k -> new Object());
-
- log.getLogger().println("[BinaryInstaller] Acquiring installation lock for: " + lockKey);
-
- // Synchronize on the specific installation location to ensure coordinated installation
- synchronized (installationLock) {
- log.getLogger().println("[BinaryInstaller] Lock acquired, proceeding with installation");
-
+
+ FilePath cliPath = toolLocation.child(binaryName);
+
+ // Fast path: binary exists and is already the correct version — skip lock entirely.
+ if (isValidCliInstallation(cliPath, log) && isCorrectVersion(toolLocation, instance, repository, version, binaryName, log)) {
+ log.getLogger().println("[BinaryInstaller] CLI already installed and up-to-date, skipping download");
+ return toolLocation;
+ }
+
+ // Slow path: need to install or upgrade.
+ String lockKey = createLockKey(toolLocation, binaryName);
+ ReentrantLock installationLock = NODE_INSTALLATION_LOCKS.computeIfAbsent(lockKey, k -> new ReentrantLock());
+
+ int timeoutMinutes = getInstallTimeoutMinutes();
+ log.getLogger().println("[BinaryInstaller] Acquiring installation lock for: " + lockKey + " (timeout: " + timeoutMinutes + " min)");
+
+ if (!installationLock.tryLock(timeoutMinutes, TimeUnit.MINUTES)) {
+ log.getLogger().println("[BinaryInstaller] WARNING: Could not acquire installation lock within " + timeoutMinutes + " minutes for: " + lockKey);
+ if (isValidCliInstallation(cliPath, log)) {
+ log.getLogger().println("[BinaryInstaller] Using existing binary while installation is in progress: " + cliPath.getRemote());
+ return toolLocation;
+ }
+ throw new IOException("Timed out after " + timeoutMinutes + " minutes waiting for JFrog CLI installation and no binary exists at: " + cliPath.getRemote() +
+ ". Set " + INSTALL_TIMEOUT_ENV_VAR + " to increase the timeout.");
+ }
+
+ log.getLogger().println("[BinaryInstaller] Lock acquired, proceeding with installation");
+ try {
+ // Re-check inside the lock — a concurrent stage may have just finished.
+ boolean validCliExists = isValidCliInstallation(cliPath, log);
+ if (validCliExists && isCorrectVersion(toolLocation, instance, repository, version, binaryName, log)) {
+ log.getLogger().println("[BinaryInstaller] CLI was installed by a concurrent stage, skipping download");
+ return toolLocation;
+ }
+
+ if (validCliExists) {
+ log.getLogger().println("[BinaryInstaller] CLI version mismatch detected, upgrading");
+ } else {
+ log.getLogger().println("[BinaryInstaller] No valid CLI installation found, downloading");
+ }
+
try {
- // Check if CLI already exists and is the correct version
- FilePath cliPath = toolLocation.child(binaryName);
- if (isValidCliInstallation(cliPath, log) && isCorrectVersion(toolLocation, instance, repository, version, log)) {
- log.getLogger().println("[BinaryInstaller] CLI already installed and up-to-date, skipping download");
- return toolLocation;
- } else if (isValidCliInstallation(cliPath, log)) {
- log.getLogger().println("[BinaryInstaller] CLI exists but version mismatch detected, proceeding with upgrade");
- } else {
- log.getLogger().println("[BinaryInstaller] No valid CLI installation found, proceeding with fresh installation");
- }
-
- // Clean up any stale lock entries for this location (different versions)
- cleanupStaleLocks(toolLocation, binaryName, version);
-
- log.getLogger().println("[BinaryInstaller] Starting CLI installation process");
-
- // Perform the actual download using the improved JFrogCliDownloader
JenkinsProxyConfiguration proxyConfiguration = new JenkinsProxyConfiguration();
toolLocation.act(new JFrogCliDownloader(proxyConfiguration, version, instance, log, repository, binaryName));
-
log.getLogger().println("[BinaryInstaller] CLI installation completed successfully");
- return toolLocation;
-
- } finally {
- log.getLogger().println("[BinaryInstaller] Installation lock released for: " + lockKey);
+ } catch (Exception e) {
+ // Download failed. If an older binary is still present, keep the pipeline running.
+ // The upgrade will be retried on the next run.
+ if (isValidCliInstallation(cliPath, log)) {
+ log.getLogger().println("[BinaryInstaller] WARNING: Download failed (" + e.getMessage() +
+ "), falling back to existing binary at: " + cliPath.getRemote());
+ return toolLocation;
+ }
+ // No binary to fall back to — this is a genuine unrecoverable failure.
+ throw new IOException("JFrog CLI download failed and no existing binary is available: " + e.getMessage(), e);
}
+
+ return toolLocation;
+
+ } finally {
+ installationLock.unlock();
+ log.getLogger().println("[BinaryInstaller] Installation lock released for: " + lockKey);
}
}
/**
- * Creates a unique lock key for the installation location and version.
- * Including version in the key ensures different versions can be installed concurrently
- * and prevents race conditions during version upgrades.
- *
+ * Returns the lock-acquisition timeout in minutes.
+ * Reads {@value #INSTALL_TIMEOUT_ENV_VAR} from the environment; falls back to
+ * {@value #DEFAULT_INSTALL_TIMEOUT_MINUTES} minutes if the variable is absent or invalid.
+ * Values below 1 are silently clamped to the default.
+ */
+ static int getInstallTimeoutMinutes() {
+ String envValue = System.getenv(INSTALL_TIMEOUT_ENV_VAR);
+ if (StringUtils.isNotBlank(envValue)) {
+ try {
+ int parsed = Integer.parseInt(envValue.trim());
+ if (parsed >= 1) {
+ return parsed;
+ }
+ LOGGER.warning(INSTALL_TIMEOUT_ENV_VAR + "=" + envValue + " is less than 1, using default " + DEFAULT_INSTALL_TIMEOUT_MINUTES + " minutes");
+ } catch (NumberFormatException e) {
+ LOGGER.warning(INSTALL_TIMEOUT_ENV_VAR + "=" + envValue + " is not a valid integer, using default " + DEFAULT_INSTALL_TIMEOUT_MINUTES + " minutes");
+ }
+ }
+ return DEFAULT_INSTALL_TIMEOUT_MINUTES;
+ }
+
+ /**
+ * Creates a unique lock key for the installation location.
+ * Version is excluded so all operations targeting the same binary path are serialized.
+ *
* @param toolLocation Installation directory
* @param binaryName Binary file name
- * @param version CLI version being installed
* @return Unique lock key string
*/
- private static String createLockKey(FilePath toolLocation, String binaryName, String version) {
+ private static String createLockKey(FilePath toolLocation, String binaryName) {
try {
- return toolLocation.getRemote() + "/" + binaryName + "/" + version;
+ return toolLocation.getRemote() + "/" + binaryName;
} catch (Exception e) {
// Fallback to a simpler key if remote path access fails
- return toolLocation.toString() + "/" + binaryName + "/" + version;
+ return "unknown-tool-location/" + binaryName;
}
}
@@ -160,16 +228,26 @@ private static String createLockKey(FilePath toolLocation, String binaryName, St
* @param log Task listener for logging
* @return true if valid CLI exists, false otherwise
*/
+ /**
+ * Checks existence, size (> 1 MB), and executable permission in a single agent RPC.
+ */
private static boolean isValidCliInstallation(FilePath cliPath, TaskListener log) {
try {
- if (cliPath.exists()) {
- // Check if file is executable and has reasonable size (> 1MB)
- long fileSize = cliPath.length();
- if (fileSize > 1024 * 1024) { // > 1MB
- log.getLogger().println("[BinaryInstaller] Found existing CLI: " + cliPath.getRemote() +
- " (size: " + (fileSize / 1024 / 1024) + "MB)");
- return true;
+ long[] result = cliPath.act(new MasterToSlaveFileCallable() {
+ @Override
+ public long[] invoke(File file, VirtualChannel channel) {
+ if (!file.exists() || file.isDirectory()) {
+ return new long[]{0, 0};
+ }
+ String name = file.getName().toLowerCase();
+ boolean executable = name.endsWith(".exe") || file.canExecute();
+ return new long[]{file.length(), executable ? 1 : 0};
}
+ });
+ if (result[0] > 1024 * 1024 && result[1] == 1) {
+ log.getLogger().println("[BinaryInstaller] Found existing CLI: " + cliPath.getRemote() +
+ " (size: " + (result[0] / 1024 / 1024) + "MB)");
+ return true;
}
} catch (Exception e) {
LOGGER.warning("Failed to check existing CLI installation: " + e.getMessage());
@@ -185,19 +263,21 @@ private static boolean isValidCliInstallation(FilePath cliPath, TaskListener log
* @param instance JFrog platform instance for version checking
* @param repository Repository containing the CLI
* @param version Version to check
+ * @param binaryName Name of the CLI binary (e.g., "jf" on Unix, "jf.exe" on Windows)
* @param log Task listener for logging
* @return true if CLI is the correct version, false otherwise
*/
private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInstance instance,
- String repository, String version, TaskListener log) {
+ String repository, String version, String binaryName, TaskListener log) {
try {
// Use the same logic as shouldDownloadTool() from JFrogCliDownloader
// but do it here to avoid unnecessary JFrogCliDownloader.invoke() calls
JenkinsProxyConfiguration proxyConfiguration = new JenkinsProxyConfiguration();
- String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/jf", repository,
+ // Use binaryName to construct the correct URL suffix (handles Windows jf.exe vs Unix jf)
+ String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/%s", repository,
StringUtils.defaultIfBlank(version, "[RELEASE]"),
- OsUtils.getOsDetails());
+ OsUtils.getOsDetails(), binaryName);
JenkinsBuildInfoLog buildInfoLog = new JenkinsBuildInfoLog(log);
String artifactoryUrl = instance.inferArtifactoryUrl();
@@ -214,8 +294,8 @@ private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInst
// Get expected SHA256 from Artifactory
String expectedSha256 = getArtifactSha256(manager, cliUrlSuffix);
if (expectedSha256.isEmpty()) {
- log.getLogger().println("[BinaryInstaller] No SHA256 available from server, assuming version check needed");
- return false; // If no SHA256, let download process handle it
+ log.getLogger().println("[BinaryInstaller] WARNING: No SHA256 available from server — cannot verify version, assuming up-to-date (upgrade may be delayed)");
+ return true;
}
// Check local SHA256 file
@@ -228,7 +308,7 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr
}
String localSha256 = new String(Files.readAllBytes(sha256File.toPath()), StandardCharsets.UTF_8);
- return constantTimeEquals(expectedSha256, localSha256);
+ return StringUtils.equals(expectedSha256, localSha256);
}
});
}
@@ -238,68 +318,22 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr
return false; // If version check fails, let download process handle it
}
}
-
+
/**
* Get SHA256 hash from Artifactory headers (same logic as in JFrogCliDownloader)
*/
private static String getArtifactSha256(ArtifactoryManager manager, String cliUrlSuffix) throws IOException {
Header[] headers = manager.downloadHeaders(cliUrlSuffix);
for (Header header : headers) {
- if (header.getName().equalsIgnoreCase("X-Checksum-Sha256")) {
+ String headerName = header.getName();
+ if (headerName.equalsIgnoreCase(SHA256_HEADER_NAME) ||
+ headerName.equalsIgnoreCase("X-Artifactory-Checksum-Sha256")) {
return header.getValue();
}
}
return "";
}
- /**
- * Clean up stale lock entries for different versions of the same CLI at the same location.
- * This prevents memory leaks in the NODE_INSTALLATION_LOCKS map during version upgrades.
- *
- * @param toolLocation Installation directory
- * @param binaryName Binary file name
- * @param currentVersion Current version being installed
- */
- private static void cleanupStaleLocks(FilePath toolLocation, String binaryName, String currentVersion) {
- try {
- String locationPrefix = toolLocation.getRemote() + "/" + binaryName + "/";
- String currentLockKey = locationPrefix + currentVersion;
-
- // Remove old version lock entries for the same location
- NODE_INSTALLATION_LOCKS.entrySet().removeIf(entry -> {
- String key = entry.getKey();
- return key.startsWith(locationPrefix) && !key.equals(currentLockKey);
- });
-
- } catch (Exception e) {
- // If cleanup fails, it's not critical - just log and continue
- LOGGER.fine("Failed to cleanup stale locks: " + e.getMessage());
- }
- }
-
- /**
- * Constant-time comparison of two strings to prevent timing attacks.
- * This is especially important for comparing cryptographic hashes like SHA256.
- *
- * @param a First string to compare
- * @param b Second string to compare
- * @return true if strings are equal, false otherwise
- */
- private static boolean constantTimeEquals(String a, String b) {
- if (a == null || b == null) {
- return Objects.equals(a, b);
- }
-
- if (a.length() != b.length()) {
- return false;
- }
-
- int result = 0;
- for (int i = 0; i < a.length(); i++) {
- result |= a.charAt(i) ^ b.charAt(i);
- }
-
- return result == 0;
- }
}
+
diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfStep.java b/src/main/java/io/jenkins/plugins/jfrog/JfStep.java
index c36b72e4..ab807be3 100644
--- a/src/main/java/io/jenkins/plugins/jfrog/JfStep.java
+++ b/src/main/java/io/jenkins/plugins/jfrog/JfStep.java
@@ -189,11 +189,18 @@ private void logIfNoToolProvided(EnvVars env, TaskListener listener) {
* @throws IOException in case of any I/O error, or we failed to run the 'jf' command
*/
public Launcher.ProcStarter setupJFrogEnvironment(Run, ?> run, EnvVars env, Launcher launcher, TaskListener listener, FilePath workspace, String jfrogBinaryPath, boolean isWindows, boolean passwordStdinSupported) throws IOException, InterruptedException {
- JFrogCliConfigEncryption jfrogCliConfigEncryption = run.getAction(JFrogCliConfigEncryption.class);
- if (jfrogCliConfigEncryption == null) {
- // Set up the config encryption action to allow encrypting the JFrog CLI configuration and make sure we only create one key
- jfrogCliConfigEncryption = new JFrogCliConfigEncryption(env);
- run.addAction(jfrogCliConfigEncryption);
+ // Synchronize on the run object to ensure only one encryption key is created
+ // even when multiple parallel stages call this method concurrently.
+ // Without synchronization, each stage would create a different random key,
+ // and the stages would encrypt/decrypt the shared config file with mismatched
+ // keys → "cipher: message authentication failed".
+ JFrogCliConfigEncryption jfrogCliConfigEncryption;
+ synchronized (run) {
+ jfrogCliConfigEncryption = run.getAction(JFrogCliConfigEncryption.class);
+ if (jfrogCliConfigEncryption == null) {
+ jfrogCliConfigEncryption = new JFrogCliConfigEncryption(env);
+ run.addAction(jfrogCliConfigEncryption);
+ }
}
FilePath jfrogHomeTempDir = Utils.createAndGetJfrogCliHomeTempDir(workspace, String.valueOf(run.getNumber()));
CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir, jfrogCliConfigEncryption);
diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java
new file mode 100644
index 00000000..22dc053e
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java
@@ -0,0 +1,231 @@
+package io.jenkins.plugins.jfrog;
+
+import hudson.EnvVars;
+import hudson.Extension;
+import hudson.FilePath;
+import hudson.Launcher;
+import hudson.model.AbstractBuild;
+import hudson.model.AbstractProject;
+import hudson.model.BuildListener;
+import hudson.model.Result;
+import hudson.tasks.BuildStepDescriptor;
+import hudson.tasks.BuildStepMonitor;
+import hudson.tasks.Notifier;
+import hudson.tasks.Publisher;
+import hudson.util.ArgumentListBuilder;
+import hudson.util.ListBoxModel;
+import io.jenkins.plugins.jfrog.actions.JFrogCliConfigEncryption;
+import org.apache.commons.lang3.StringUtils;
+import org.jenkinsci.Symbol;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+
+import javax.annotation.Nonnull;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import static io.jenkins.plugins.jfrog.JfStep.addBuildInfoActionIfNeeded;
+import static io.jenkins.plugins.jfrog.JfrogInstallation.JFROG_BINARY_PATH;
+
+/**
+ * Post-build action to publish JFrog Build Info.
+ * This automatically runs 'jf rt build-publish' after the build completes,
+ * publishing collected build information to Artifactory.
+ */
+public class JfrogBuildInfoPublisher extends Notifier {
+
+ private String jfrogInstallation;
+ private boolean publishOnlyOnSuccess = true;
+
+ @DataBoundConstructor
+ public JfrogBuildInfoPublisher() {
+ }
+
+ public String getJfrogInstallation() {
+ return jfrogInstallation;
+ }
+
+ @DataBoundSetter
+ public void setJfrogInstallation(String jfrogInstallation) {
+ this.jfrogInstallation = jfrogInstallation;
+ }
+
+ public boolean isPublishOnlyOnSuccess() {
+ return publishOnlyOnSuccess;
+ }
+
+ @DataBoundSetter
+ public void setPublishOnlyOnSuccess(boolean publishOnlyOnSuccess) {
+ this.publishOnlyOnSuccess = publishOnlyOnSuccess;
+ }
+
+ @Override
+ public BuildStepMonitor getRequiredMonitorService() {
+ return BuildStepMonitor.NONE;
+ }
+
+ @Override
+ public boolean perform(AbstractBuild, ?> build, Launcher launcher, BuildListener listener)
+ throws InterruptedException, IOException {
+
+ // Check if we should skip based on build result
+ Result buildResult = build.getResult();
+ if (publishOnlyOnSuccess && buildResult != null && buildResult.isWorseThan(Result.SUCCESS)) {
+ listener.getLogger().println("[JFrog Build Info] Skipping publish - build result is " + buildResult);
+ return true;
+ }
+
+ FilePath workspace = build.getWorkspace();
+ if (workspace == null) {
+ listener.error("[JFrog Build Info] Workspace is null");
+ return false;
+ }
+
+ EnvVars env = build.getEnvironment(listener);
+
+ // Setup JFrog CLI installation environment if specified
+ if (StringUtils.isNotBlank(jfrogInstallation)) {
+ JfrogInstallation installation = getInstallation();
+ if (installation != null) {
+ hudson.model.Node node = build.getBuiltOn();
+ if (node != null) {
+ installation = installation.forNode(node, listener);
+ }
+ if (installation != null) {
+ installation = installation.forEnvironment(env);
+ }
+ if (installation != null) {
+ installation.buildEnvVars(env);
+ }
+ }
+ }
+
+ // Check if JFROG_BINARY_PATH is set (either from installation or Build Environment wrapper)
+ if (!env.containsKey(JFROG_BINARY_PATH)) {
+ listener.getLogger().println("[JFrog Build Info] Using JFrog CLI from system PATH");
+ }
+
+ boolean isWindows = !launcher.isUnix();
+ String jfrogBinaryPath = Utils.getJFrogCLIPath(env, isWindows);
+
+ // Setup JFrog environment
+ JFrogCliConfigEncryption jfrogCliConfigEncryption = build.getAction(JFrogCliConfigEncryption.class);
+ if (jfrogCliConfigEncryption == null) {
+ jfrogCliConfigEncryption = new JFrogCliConfigEncryption(env);
+ build.addAction(jfrogCliConfigEncryption);
+ }
+
+ FilePath jfrogHomeTempDir = Utils.createAndGetJfrogCliHomeTempDir(workspace, String.valueOf(build.getNumber()));
+ CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir, jfrogCliConfigEncryption);
+
+ // Build the 'jf rt build-publish' command
+ ArgumentListBuilder builder = new ArgumentListBuilder();
+ builder.add(jfrogBinaryPath).add("rt").add("bp");
+ if (isWindows) {
+ builder = builder.toWindowsCommand();
+ }
+
+ listener.getLogger().println("[JFrog Build Info] Publishing build info...");
+ listener.getLogger().println("[JFrog Build Info] Build name: " + env.get("JFROG_CLI_BUILD_NAME"));
+ listener.getLogger().println("[JFrog Build Info] Build number: " + env.get("JFROG_CLI_BUILD_NUMBER"));
+
+ try (ByteArrayOutputStream taskOutputStream = new ByteArrayOutputStream()) {
+ JfTaskListener jfTaskListener = new JfTaskListener(listener, taskOutputStream);
+ Launcher.ProcStarter jfLauncher = launcher.launch()
+ .envs(env)
+ .pwd(workspace)
+ .stdout(jfTaskListener);
+
+ // Configure servers if needed
+ if (shouldConfig(jfrogHomeTempDir)) {
+ JfStep.Execution.configAllServersForBuilder(
+ jfLauncher, jfrogBinaryPath, isWindows, build.getParent(), false
+ );
+ }
+
+ // Run 'jf rt bp'
+ int exitValue = jfLauncher.cmds(builder).join();
+ if (exitValue != 0) {
+ listener.error("[JFrog Build Info] Failed to publish build info (exit code: " + exitValue + ")");
+ return false;
+ }
+
+ // Add build info badge to Jenkins UI
+ String[] args = {"rt", "bp"};
+ addBuildInfoActionIfNeeded(args, new JenkinsBuildInfoLog(listener), build, taskOutputStream);
+
+ listener.getLogger().println("[JFrog Build Info] Build info published successfully");
+ return true;
+ } catch (Exception e) {
+ listener.error("[JFrog Build Info] Error publishing build info: " + e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Check if servers need to be configured.
+ */
+ private boolean shouldConfig(FilePath jfrogHomeTempDir) throws IOException, InterruptedException {
+ if (jfrogHomeTempDir == null || !jfrogHomeTempDir.exists()) {
+ return true;
+ }
+ return !jfrogHomeTempDir.child("jfrog-cli.conf").exists();
+ }
+
+ /**
+ * Get the JFrog installation by name.
+ */
+ private JfrogInstallation getInstallation() {
+ if (jfrogInstallation == null) {
+ return null;
+ }
+
+ JfrogInstallation[] installations = ((DescriptorImpl) getDescriptor()).getInstallations();
+ if (installations == null) {
+ return null;
+ }
+
+ for (JfrogInstallation installation : installations) {
+ if (installation != null && jfrogInstallation.equals(installation.getName())) {
+ return installation;
+ }
+ }
+ return null;
+ }
+
+ @Extension
+ @Symbol("jfrogPublishBuildInfo")
+ public static final class DescriptorImpl extends BuildStepDescriptor {
+
+ @Nonnull
+ @Override
+ public String getDisplayName() {
+ return "Publish JFrog Build Info";
+ }
+
+ @Override
+ public boolean isApplicable(Class extends AbstractProject> jobType) {
+ return true;
+ }
+
+ /**
+ * Get all configured JFrog CLI installations.
+ */
+ public JfrogInstallation[] getInstallations() {
+ jenkins.model.Jenkins jenkinsInstance = jenkins.model.Jenkins.get();
+ return jenkinsInstance.getDescriptorByType(JfrogInstallation.DescriptorImpl.class).getInstallations();
+ }
+
+ /**
+ * Populate the dropdown list of JFrog CLI installations.
+ */
+ public ListBoxModel doFillJfrogInstallationItems() {
+ ListBoxModel items = new ListBoxModel();
+ items.add("(Use pre-installed JFrog CLI from system PATH)", "");
+ for (JfrogInstallation installation : getInstallations()) {
+ items.add(installation.getName(), installation.getName());
+ }
+ return items;
+ }
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java
index bc914f95..d407cc7f 100644
--- a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java
+++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java
@@ -352,7 +352,7 @@ public JfrogInstallation[] getInstallations() {
*/
public ListBoxModel doFillJfrogInstallationItems() {
ListBoxModel items = new ListBoxModel();
- items.add("(Use JFrog CLI from system PATH)", "");
+ items.add("(Use pre-installed JFrog CLI from system PATH)", "");
for (JfrogInstallation installation : getInstallations()) {
items.add(installation.getName(), installation.getName());
}
diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java
new file mode 100644
index 00000000..20782e30
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java
@@ -0,0 +1,181 @@
+package io.jenkins.plugins.jfrog;
+
+import hudson.EnvVars;
+import hudson.Extension;
+import hudson.FilePath;
+import hudson.Launcher;
+import hudson.model.AbstractProject;
+import hudson.model.Run;
+import hudson.model.TaskListener;
+import hudson.tasks.BuildWrapperDescriptor;
+import hudson.util.ListBoxModel;
+import jenkins.tasks.SimpleBuildWrapper;
+import org.jenkinsci.Symbol;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+
+import javax.annotation.Nonnull;
+import java.io.IOException;
+
+/**
+ * Build Environment wrapper that sets up JFrog CLI for the entire build.
+ * This allows all build steps in a Freestyle job to use the configured JFrog CLI
+ * installation without having to specify it in each step.
+ *
+ * When this wrapper is enabled, the JFROG_BINARY_PATH environment variable is set
+ * for the entire build, making the 'jf' command available to all build steps,
+ * including shell scripts and other plugins.
+ *
+ * Note: This wrapper is only available for Freestyle jobs, not Matrix jobs.
+ * Matrix jobs run across multiple nodes where CLI installations may differ.
+ * For Matrix jobs, use individual "Run JFrog CLI" build steps with automatic
+ * installation configured, which will download the CLI to each node as needed.
+ */
+public class JfrogCliWrapper extends SimpleBuildWrapper {
+
+ private String jfrogInstallation;
+
+ @DataBoundConstructor
+ public JfrogCliWrapper() {
+ }
+
+ public String getJfrogInstallation() {
+ return jfrogInstallation;
+ }
+
+ @DataBoundSetter
+ public void setJfrogInstallation(String jfrogInstallation) {
+ this.jfrogInstallation = jfrogInstallation;
+ }
+
+ @Override
+ public void setUp(
+ Context context,
+ Run, ?> build,
+ FilePath workspace,
+ Launcher launcher,
+ TaskListener listener,
+ EnvVars initialEnvironment
+ ) throws IOException, InterruptedException {
+ if (jfrogInstallation == null || jfrogInstallation.isEmpty()) {
+ listener.getLogger().println("[JFrog CLI] No installation selected, using system PATH");
+ return;
+ }
+
+ JfrogInstallation installation = getInstallation();
+ if (installation == null) {
+ listener.error("[JFrog CLI] Installation '" + jfrogInstallation + "' not found");
+ return;
+ }
+
+ // Resolve the installation for the current node
+ hudson.model.Node node = workspaceToNode(workspace);
+ if (node != null) {
+ installation = installation.forNode(node, listener);
+ }
+ installation = installation.forEnvironment(initialEnvironment);
+
+ // Add environment variables that will persist for the entire build
+ EnvVars envVars = new EnvVars();
+ installation.buildEnvVars(envVars);
+
+ for (java.util.Map.Entry entry : envVars.entrySet()) {
+ context.env(entry.getKey(), entry.getValue());
+ }
+
+ listener.getLogger().println("[JFrog CLI] Using installation: " + jfrogInstallation);
+ listener.getLogger().println("[JFrog CLI] Binary path: " + envVars.get(JfrogInstallation.JFROG_BINARY_PATH));
+ }
+
+ /**
+ * Get the JFrog installation by name.
+ */
+ private JfrogInstallation getInstallation() {
+ if (jfrogInstallation == null) {
+ return null;
+ }
+
+ JfrogInstallation[] installations = ((DescriptorImpl) getDescriptor()).getInstallations();
+ if (installations == null) {
+ return null;
+ }
+
+ for (JfrogInstallation installation : installations) {
+ if (installation != null && jfrogInstallation.equals(installation.getName())) {
+ return installation;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get the node from workspace using the Computer API.
+ */
+ private hudson.model.Node workspaceToNode(FilePath workspace) {
+ if (workspace == null) {
+ return null;
+ }
+ hudson.model.Computer computer = workspace.toComputer();
+ if (computer == null) {
+ return null;
+ }
+ return computer.getNode();
+ }
+
+ @Extension
+ @Symbol("jfrogCliEnv")
+ public static final class DescriptorImpl extends BuildWrapperDescriptor {
+
+ @Nonnull
+ @Override
+ public String getDisplayName() {
+ return "Set up JFrog CLI environment";
+ }
+
+ /**
+ * This wrapper is only available for Freestyle jobs, not Matrix jobs.
+ *
+ * Matrix jobs run across multiple nodes where CLI installations need to be
+ * handled per-node. For Matrix jobs, users should use individual "Run JFrog CLI"
+ * build steps with automatic installation configured, which will download
+ * the CLI to each node as needed.
+ *
+ * @param item The project to check
+ * @return true if this wrapper can be used with the project
+ */
+ @Override
+ public boolean isApplicable(AbstractProject, ?> item) {
+ // Exclude Matrix projects - they run across multiple nodes where CLI installations may differ.
+ // Use reflection to avoid a hard compile-time dependency on the matrix-project plugin.
+ try {
+ Class> matrixProjectClass = Class.forName("hudson.matrix.MatrixProject");
+ if (matrixProjectClass.isInstance(item)) {
+ return false;
+ }
+ } catch (ClassNotFoundException e) {
+ // matrix-project plugin not installed; nothing to exclude
+ }
+ return true;
+ }
+
+ /**
+ * Get all configured JFrog CLI installations.
+ */
+ public JfrogInstallation[] getInstallations() {
+ jenkins.model.Jenkins jenkinsInstance = jenkins.model.Jenkins.get();
+ return jenkinsInstance.getDescriptorByType(JfrogInstallation.DescriptorImpl.class).getInstallations();
+ }
+
+ /**
+ * Populate the dropdown list of JFrog CLI installations.
+ */
+ public ListBoxModel doFillJfrogInstallationItems() {
+ ListBoxModel items = new ListBoxModel();
+ items.add("(Use pre-installed JFrog CLI from system PATH)", "");
+ for (JfrogInstallation installation : getInstallations()) {
+ items.add(installation.getName(), installation.getName());
+ }
+ return items;
+ }
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java
index f63355fd..e40272e5 100644
--- a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java
+++ b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java
@@ -16,8 +16,11 @@
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.FileSystemException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
import static org.jfrog.build.client.DownloadResponse.SHA256_HEADER_NAME;
@@ -38,6 +41,12 @@ public class JFrogCliDownloader extends MasterToSlaveFileCallable {
* decoded "[RELEASE]" for the download url
*/
private static final String RELEASE = "[RELEASE]";
+
+ /**
+ * Minimum valid CLI binary size in bytes (1MB).
+ * Used to determine if an existing CLI installation is valid.
+ */
+ private static final long MIN_VALID_CLI_SIZE = 1024 * 1024;
JenkinsProxyConfiguration proxyConfiguration;
private String providedVersion;
@@ -50,6 +59,111 @@ public class JFrogCliDownloader extends MasterToSlaveFileCallable {
public Void invoke(File toolLocation, VirtualChannel channel) throws IOException, InterruptedException {
log.getLogger().println("[JFrogCliDownloader] Starting CLI download");
+ // Ensure the tool location directory exists
+ if (!toolLocation.exists()) {
+ if (!toolLocation.mkdirs()) {
+ throw new IOException("Failed to create tool location directory: " + toolLocation.getAbsolutePath());
+ }
+ }
+
+ // Check if this is a fresh install or an upgrade
+ File existingCli = new File(toolLocation, binaryName);
+ boolean isFreshInstall = !isExistingCliValid(existingCli);
+
+ if (isFreshInstall) {
+ log.getLogger().println("[JFrogCliDownloader] Fresh installation detected");
+ performDownloadWithLock(toolLocation);
+ } else {
+ log.getLogger().println("[JFrogCliDownloader] Existing CLI found - attempting upgrade");
+ performDownloadWithLockForUpgrade(toolLocation, existingCli);
+ }
+
+ return null;
+ }
+
+ /**
+ * Performs download for upgrade scenario with graceful fallback.
+ * If the binary is locked during replacement, we skip the upgrade and use existing.
+ *
+ * @param toolLocation The target directory
+ * @param existingCli The existing CLI binary
+ * @throws IOException If download fails for non-recoverable reasons
+ * @throws InterruptedException If interrupted
+ */
+ private void performDownloadWithLockForUpgrade(File toolLocation, File existingCli) throws IOException, InterruptedException {
+ String version = StringUtils.defaultIfBlank(providedVersion, RELEASE);
+ String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/%s", repository, version, OsUtils.getOsDetails(), binaryName);
+
+ JenkinsBuildInfoLog buildInfoLog = new JenkinsBuildInfoLog(log);
+ String artifactoryUrl = instance.inferArtifactoryUrl();
+
+ try (ArtifactoryManager manager = new ArtifactoryManager(artifactoryUrl,
+ Secret.toString(instance.getCredentialsConfig().getUsername()),
+ Secret.toString(instance.getCredentialsConfig().getPassword()),
+ Secret.toString(instance.getCredentialsConfig().getAccessToken()), buildInfoLog)) {
+
+ if (proxyConfiguration.isProxyConfigured(artifactoryUrl)) {
+ manager.setProxyConfiguration(proxyConfiguration);
+ }
+
+ String artifactorySha256 = getArtifactSha256(manager, cliUrlSuffix);
+
+ if (!shouldDownloadTool(toolLocation, artifactorySha256)) {
+ log.getLogger().println("[JFrogCliDownloader] CLI is up-to-date, skipping download");
+ return;
+ }
+
+ if (version.equals(RELEASE)) {
+ log.getLogger().printf("[JFrogCliDownloader] Upgrading '%s' to latest version from: %s%n",
+ binaryName, artifactoryUrl + cliUrlSuffix);
+ } else {
+ log.getLogger().printf("[JFrogCliDownloader] Upgrading '%s' to version %s from: %s%n",
+ binaryName, version, artifactoryUrl + cliUrlSuffix);
+ }
+
+ // Attempt upgrade with graceful fallback
+ boolean upgradeSucceeded = performAtomicDownloadForUpgrade(manager, cliUrlSuffix, toolLocation,
+ artifactorySha256, existingCli);
+
+ if (upgradeSucceeded) {
+ log.getLogger().println("[JFrogCliDownloader] Upgrade completed successfully");
+ } else {
+ log.getLogger().println("[JFrogCliDownloader] Upgrade skipped, using existing CLI version");
+ }
+ }
+ }
+
+ /**
+ * Checks if an existing CLI binary is valid and usable.
+ * A valid CLI exists and has a reasonable file size (> 1MB).
+ *
+ * @param cliFile The CLI binary file to check
+ * @return true if CLI is valid and usable, false otherwise
+ */
+ private boolean isExistingCliValid(File cliFile) {
+ if (!cliFile.exists()) {
+ return false;
+ }
+
+ long fileSize = cliFile.length();
+ boolean isValid = fileSize >= MIN_VALID_CLI_SIZE;
+
+ if (isValid) {
+ log.getLogger().println("[JFrogCliDownloader] Found valid existing CLI: " + cliFile.getAbsolutePath() +
+ " (size: " + (fileSize / 1024 / 1024) + "MB)");
+ }
+
+ return isValid;
+ }
+
+ /**
+ * Performs the actual download operation for fresh installations.
+ *
+ * @param toolLocation The target directory for CLI installation
+ * @throws IOException If download fails
+ * @throws InterruptedException If interrupted during download
+ */
+ private void performDownloadWithLock(File toolLocation) throws IOException, InterruptedException {
// An empty string indicates the latest version.
String version = StringUtils.defaultIfBlank(providedVersion, RELEASE);
String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/%s", repository, version, OsUtils.getOsDetails(), binaryName);
@@ -63,7 +177,7 @@ public Void invoke(File toolLocation, VirtualChannel channel) throws IOException
if (proxyConfiguration.isProxyConfigured(artifactoryUrl)) {
manager.setProxyConfiguration(proxyConfiguration);
}
- // Getting updated cli binary's sha256 form Artifactory.
+ // Getting updated cli binary's sha256 from Artifactory.
String artifactorySha256 = getArtifactSha256(manager, cliUrlSuffix);
if (shouldDownloadTool(toolLocation, artifactorySha256)) {
if (version.equals(RELEASE)) {
@@ -81,94 +195,220 @@ public Void invoke(File toolLocation, VirtualChannel channel) throws IOException
}
log.getLogger().println("[JFrogCliDownloader] Download completed successfully");
- return null;
}
/**
- * Performs atomic download operations for reliable file installation.
- *
- * APPROACH:
- * 1. Generate unique temporary file name to avoid conflicts
- * 2. Download to temporary file
- * 3. Verify download integrity
- * 4. Atomic move from temp to final location
- * 5. Set executable permissions
- * 6. Create SHA256 verification file
- * 7. Cleanup temporary file on any failure
- *
+ * Downloads the CLI binary to a unique temporary file and verifies its integrity.
+ *
+ * @param manager ArtifactoryManager for download operations
+ * @param cliUrlSuffix URL suffix for the CLI binary
+ * @param toolLocation Target directory for installation
+ * @return The downloaded temporary file (caller is responsible for cleanup)
+ * @throws IOException If download or verification fails
+ */
+ private File downloadToTemp(ArtifactoryManager manager, String cliUrlSuffix, File toolLocation) throws IOException {
+ String stageName = getStageNameFromThread();
+ String tempFileName = binaryName + ".tmp." +
+ stageName + "." +
+ System.currentTimeMillis() + "." +
+ Thread.currentThread().getId() + "." +
+ System.nanoTime();
+
+ File temporaryDownloadFile = new File(toolLocation, tempFileName);
+ log.getLogger().println("[JFrogCliDownloader] Downloading to temporary file: " + temporaryDownloadFile.getAbsolutePath());
+ manager.downloadToFile(cliUrlSuffix, temporaryDownloadFile.getPath());
+
+ if (!temporaryDownloadFile.exists()) {
+ throw new IOException("Downloaded file doesn't exist: " + temporaryDownloadFile.getAbsolutePath());
+ }
+ long fileSize = temporaryDownloadFile.length();
+ if (fileSize == 0) {
+ throw new IOException("Downloaded file is empty: " + temporaryDownloadFile.getAbsolutePath());
+ }
+ log.getLogger().println("[JFrogCliDownloader] Download verified: " + (fileSize / 1024 / 1024) + "MB");
+ return temporaryDownloadFile;
+ }
+
+ /**
+ * Sets executable permissions and creates the SHA256 verification file after a successful move.
+ */
+ private void finalizeInstall(File finalCliExecutable, File toolLocation, String artifactorySha256) throws IOException {
+ log.getLogger().println("[JFrogCliDownloader] Setting executable permissions");
+ if (!finalCliExecutable.setExecutable(true)) {
+ throw new IOException("No permission to add execution permission to binary: " + finalCliExecutable.getAbsolutePath());
+ }
+ log.getLogger().println("[JFrogCliDownloader] Creating SHA256 verification file");
+ createSha256File(toolLocation, artifactorySha256);
+ }
+
+ /**
+ * Performs atomic download for fresh installations. Always throws on failure.
+ *
* @param manager ArtifactoryManager for download operations
* @param cliUrlSuffix URL suffix for the CLI binary
* @param toolLocation Target directory for installation
* @param artifactorySha256 Expected SHA256 hash for verification
* @throws IOException If download or file operations fail
*/
- private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuffix,
- File toolLocation, String artifactorySha256) throws IOException {
-
- // Phase 1: Generate unique temporary file name to avoid conflicts during parallel downloads
- String stageName = getStageNameFromThread();
- String tempFileName = binaryName + ".tmp." +
- stageName + "." +
- System.currentTimeMillis() + "." +
- Thread.currentThread().getId() + "." +
- System.nanoTime();
-
- File temporaryDownloadFile = new File(toolLocation, tempFileName); // Temp file for download
- File finalCliExecutable = new File(toolLocation, binaryName); // Final CLI binary location
-
- log.getLogger().println("[JFrogCliDownloader] Temporary download file: " + temporaryDownloadFile.getAbsolutePath());
- log.getLogger().println("[JFrogCliDownloader] Final CLI executable: " + finalCliExecutable.getAbsolutePath());
-
+ private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuffix,
+ File toolLocation, String artifactorySha256) throws IOException {
+ File finalCliExecutable = new File(toolLocation, binaryName);
+ File temporaryDownloadFile = null;
try {
- // Download to temporary file
- log.getLogger().println("[JFrogCliDownloader] Downloading to temporary file");
- File downloadResponse = manager.downloadToFile(cliUrlSuffix, temporaryDownloadFile.getPath());
-
- // Verify download integrity
- log.getLogger().println("[JFrogCliDownloader] Verifying download integrity");
- if (!temporaryDownloadFile.exists()) {
- throw new IOException("Downloaded file doesn't exist: " + temporaryDownloadFile.getAbsolutePath());
+ temporaryDownloadFile = downloadToTemp(manager, cliUrlSuffix, toolLocation);
+ log.getLogger().println("[JFrogCliDownloader] Moving to final location: " + finalCliExecutable.getAbsolutePath());
+ moveFileWithRetry(temporaryDownloadFile, finalCliExecutable);
+ finalizeInstall(finalCliExecutable, toolLocation, artifactorySha256);
+ log.getLogger().println("[JFrogCliDownloader] Download and installation completed successfully");
+ } catch (Exception e) {
+ log.getLogger().println("[JFrogCliDownloader] Download failed, cleaning up temporary file");
+ cleanupTempFile(temporaryDownloadFile);
+ throw e;
+ }
+ }
+
+ /**
+ * Performs atomic download for upgrade scenario with graceful fallback.
+ * Returns false if the target binary is locked on Windows, allowing the caller to use the existing CLI.
+ *
+ * @param manager ArtifactoryManager for download operations
+ * @param cliUrlSuffix URL suffix for the CLI binary
+ * @param toolLocation Target directory for installation
+ * @param artifactorySha256 Expected SHA256 hash for verification
+ * @param existingCli The existing CLI binary file
+ * @return true if upgrade succeeded, false if skipped due to file locking
+ * @throws IOException If download fails for non-recoverable reasons (not file locking)
+ */
+ private boolean performAtomicDownloadForUpgrade(ArtifactoryManager manager, String cliUrlSuffix,
+ File toolLocation, String artifactorySha256,
+ File existingCli) throws IOException {
+ File finalCliExecutable = new File(toolLocation, binaryName);
+ File temporaryDownloadFile = null;
+ try {
+ temporaryDownloadFile = downloadToTemp(manager, cliUrlSuffix, toolLocation);
+ log.getLogger().println("[JFrogCliDownloader] Attempting to replace existing CLI: " + finalCliExecutable.getAbsolutePath());
+ boolean moveSucceeded = tryMoveFileForUpgrade(temporaryDownloadFile, finalCliExecutable);
+ if (!moveSucceeded) {
+ log.getLogger().println("[JFrogCliDownloader] WARNING: Existing CLI is in use by another process. " +
+ "Upgrade skipped. Using existing version. Upgrade will be attempted in next build.");
+ cleanupTempFile(temporaryDownloadFile);
+ return false;
}
-
- long fileSize = temporaryDownloadFile.length();
- if (fileSize == 0) {
- throw new IOException("Downloaded file is empty: " + temporaryDownloadFile.getAbsolutePath());
+ finalizeInstall(finalCliExecutable, toolLocation, artifactorySha256);
+ return true;
+ } catch (IOException e) {
+ if (isWindowsTarget() && isFileLockingError(e) && isExistingCliValid(existingCli)) {
+ log.getLogger().println("[JFrogCliDownloader] WARNING: Upgrade failed due to file locking. " +
+ "Using existing CLI version. Upgrade will be attempted in next build.");
+ cleanupTempFile(temporaryDownloadFile);
+ return false;
}
-
- log.getLogger().println("[JFrogCliDownloader] Download verified: " + (fileSize / 1024 / 1024) + "MB");
-
- // Atomic move to final location (only delete existing if move will succeed)
- log.getLogger().println("[JFrogCliDownloader] Moving to final location");
- if (finalCliExecutable.exists()) {
- log.getLogger().println("[JFrogCliDownloader] Removing existing CLI binary to replace with new version");
- if (!finalCliExecutable.delete()) {
- throw new IOException("Failed to remove existing CLI binary: " + finalCliExecutable.getAbsolutePath());
+ cleanupTempFile(temporaryDownloadFile);
+ throw e;
+ }
+ }
+
+ /**
+ * Attempts to move a file for upgrade scenario.
+ * Returns false if file is locked (instead of throwing), allowing graceful fallback.
+ *
+ * @param source Source file to move
+ * @param target Target location
+ * @return true if move succeeded, false if target is locked
+ * @throws IOException If move fails for non-locking reasons
+ */
+ private boolean tryMoveFileForUpgrade(File source, File target) throws IOException {
+ try {
+ Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ log.getLogger().println("[JFrogCliDownloader] File moved successfully to: " + target.getAbsolutePath());
+ return true;
+ } catch (IOException e) {
+ if (isFileLockingError(e)) {
+ if (!isWindowsTarget()) {
+ throw e;
}
+ log.getLogger().println("[JFrogCliDownloader] Target file is locked: " + e.getMessage());
+ return false;
}
-
- // Atomic move from temporary file to final location
- if (!temporaryDownloadFile.renameTo(finalCliExecutable)) {
- throw new IOException("Failed to move temporary file to final location. Temp: " +
- temporaryDownloadFile.getAbsolutePath() + ", Final: " + finalCliExecutable.getAbsolutePath());
+ throw e;
+ }
+ }
+
+ /**
+ * Checks if an exception is caused by Windows file locking.
+ *
+ * @param e The exception to check
+ * @return true if this is a file locking error
+ */
+ private boolean isFileLockingError(Exception e) {
+ Throwable current = e;
+ while (current != null) {
+ if (current instanceof AccessDeniedException) {
+ return true;
}
-
- // Set executable permissions on final CLI binary
- log.getLogger().println("[JFrogCliDownloader] Setting executable permissions");
- if (!finalCliExecutable.setExecutable(true)) {
- throw new IOException("No permission to add execution permission to binary: " + finalCliExecutable.getAbsolutePath());
+ if (current instanceof FileSystemException) {
+ String reason = ((FileSystemException) current).getReason();
+ if (containsLockingMessage(reason)) {
+ return true;
+ }
+ }
+ if (containsLockingMessage(current.getMessage())) {
+ return true;
+ }
+ current = current.getCause();
+ }
+ return false;
+ }
+
+ private boolean containsLockingMessage(String message) {
+ if (message == null) {
+ return false;
+ }
+ return message.contains("being used by another process") ||
+ message.contains("Access is denied") ||
+ message.contains("cannot access the file") ||
+ message.contains("The process cannot access the file");
+ }
+
+ /**
+ * Moves a file to the target location with retry logic for Windows file locking issues.
+ * Uses Java NIO Files.move with REPLACE_EXISTING option for atomic operation.
+ * On Windows, if the target file is locked (e.g., being scanned by antivirus),
+ * this method will retry with exponential backoff.
+ *
+ * @param source Source file to move
+ * @param target Target location
+ * @throws IOException If move fails after all retries
+ */
+ private void moveFileWithRetry(File source, File target) throws IOException {
+ int maxRetries = 5;
+ long retryDelayMs = 1000;
+
+ for (int attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ // Use Files.move with REPLACE_EXISTING for atomic replacement.
+ // Note: ATOMIC_MOVE is not always supported on Windows, so we use REPLACE_EXISTING.
+ Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ log.getLogger().println("[JFrogCliDownloader] File moved successfully to: " + target.getAbsolutePath());
+ return;
+ } catch (IOException e) {
+ boolean isLastAttempt = (attempt == maxRetries);
+ if (isFileLockingError(e) && !isLastAttempt) {
+ log.getLogger().println("[JFrogCliDownloader] File locked, retrying in " +
+ retryDelayMs + "ms (attempt " + attempt + "/" + maxRetries + ")");
+ try {
+ Thread.sleep(retryDelayMs);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while waiting to retry file move", ie);
+ }
+ retryDelayMs *= 2;
+ } else {
+ throw new IOException("Failed to move file from " + source.getAbsolutePath() +
+ " to " + target.getAbsolutePath() +
+ " after " + attempt + " attempts: " + e.getMessage(), e);
+ }
}
-
- // Create SHA256 verification file
- log.getLogger().println("[JFrogCliDownloader] Creating SHA256 verification file");
- createSha256File(toolLocation, artifactorySha256);
-
- log.getLogger().println("[JFrogCliDownloader] Download and installation completed successfully");
-
- } catch (Exception e) {
- // Cleanup temporary file on failure
- log.getLogger().println("[JFrogCliDownloader] Download failed, cleaning up temporary file");
- cleanupTempFile(temporaryDownloadFile);
- throw e;
}
}
@@ -191,9 +431,21 @@ private void cleanupTempFile(File tempFile) {
}
}
+ /**
+ * Atomically writes the sha256 verification file using a temp-then-rename approach.
+ * A plain {@code Files.write()} truncates the target file before writing content,
+ * creating a brief window where readers see a 0-byte file and treat the installation
+ * as stale (triggering a re-download loop). Writing to a temp file and renaming
+ * eliminates that window because the rename is atomic on all supported file systems.
+ */
private static void createSha256File(File toolLocation, String artifactorySha256) throws IOException {
- File file = new File(toolLocation, SHA256_FILE_NAME);
- Files.write(file.toPath(), artifactorySha256.getBytes(StandardCharsets.UTF_8));
+ if (StringUtils.isBlank(artifactorySha256)) {
+ return;
+ }
+ File targetFile = new File(toolLocation, SHA256_FILE_NAME);
+ File tempFile = new File(toolLocation, SHA256_FILE_NAME + ".tmp");
+ Files.write(tempFile.toPath(), artifactorySha256.getBytes(StandardCharsets.UTF_8));
+ Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
/**
@@ -201,11 +453,16 @@ private static void createSha256File(File toolLocation, String artifactorySha256
* A file named 'sha256' contains the specific binary sha256.
* If the file sha256 has not changed, we will skip the download, otherwise we will download and overwrite the existing files.
*
+ * A 0-byte sha256 file (left by a previous failed write or an older plugin version)
+ * is treated the same as a missing file: the tool is re-downloaded and the file is
+ * replaced with the correct hash. The stale 0-byte file is deleted immediately so it
+ * cannot confuse any concurrent readers.
+ *
* @param toolLocation - expected location of the tool on the fileSystem.
* @param artifactorySha256 - sha256 of the expected file in artifactory.
*/
private static boolean shouldDownloadTool(File toolLocation, String artifactorySha256) throws IOException {
- // In case no sha256 was provided (for example when the customer blocks headers) download the tool.
+ // In case no sha256 was provided (for example when the users blocks headers) download the tool.
if (artifactorySha256.isEmpty()) {
return true;
}
@@ -215,6 +472,12 @@ private static boolean shouldDownloadTool(File toolLocation, String artifactoryS
return true;
}
String fileContent = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
+ if (StringUtils.isBlank(fileContent)) {
+ // 0-byte or whitespace-only sha256 file — corrupted/legacy state.
+ // Delete it so a fresh, correct file is written after the download.
+ Files.deleteIfExists(path);
+ return true;
+ }
return !StringUtils.equals(fileContent, artifactorySha256);
}
@@ -229,7 +492,9 @@ private static boolean shouldDownloadTool(File toolLocation, String artifactoryS
private static String getArtifactSha256(ArtifactoryManager manager, String cliUrlSuffix) throws IOException {
Header[] headers = manager.downloadHeaders(cliUrlSuffix);
for (Header header : headers) {
- if (header.getName().equalsIgnoreCase(SHA256_HEADER_NAME)) {
+ String headerName = header.getName();
+ if (headerName.equalsIgnoreCase(SHA256_HEADER_NAME) ||
+ headerName.equalsIgnoreCase("X-Artifactory-Checksum-Sha256")) {
return header.getValue();
}
}
@@ -258,4 +523,11 @@ private String getStageNameFromThread() {
return "unknown";
}
}
+
+ /**
+ * Determine whether the target CLI binary is Windows.
+ */
+ private boolean isWindowsTarget() {
+ return binaryName != null && binaryName.toLowerCase().endsWith(".exe");
+ }
}
diff --git a/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher/config.jelly b/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher/config.jelly
new file mode 100644
index 00000000..f4b02e82
--- /dev/null
+++ b/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher/config.jelly
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuilder/config.jelly b/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuilder/config.jelly
index 78970333..04e2d5a9 100644
--- a/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuilder/config.jelly
+++ b/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuilder/config.jelly
@@ -1,7 +1,7 @@
+ description="Select the JFrog CLI installation to use. If you choose 'Use pre-installed JFrog CLI from system PATH', the 'jf' command must already be installed and available in the PATH on your Jenkins agent. Otherwise, configure automatic installations in 'Manage Jenkins' → 'Global Tool Configuration'.">
+
+
+
+
+
diff --git a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java
index af99e115..a0c49179 100644
--- a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java
+++ b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java
@@ -53,17 +53,8 @@ public void configEncryptionTest() throws IOException, InterruptedException {
JFrogCliConfigEncryption configEncryption = new JFrogCliConfigEncryption(envVars);
assertTrue(configEncryption.shouldEncrypt());
assertEquals(32, configEncryption.getKey().length());
-
- File jfrogHomeDir = tempFolder.newFolder("jfrog-home-enc");
- FilePath jfrogHomeTempDir = new FilePath(jfrogHomeDir);
- invokeConfigureCliEnv(jfrogHomeTempDir, configEncryption);
- // The encryption key file is created in jfrogHomeTempDir/encryption/ to work in Docker containers
- String keyFilePath = envVars.get(JFROG_CLI_ENCRYPTION_KEY);
- assertNotNull(keyFilePath);
- assertTrue(keyFilePath.startsWith(jfrogHomeDir.getAbsolutePath()));
- assertTrue(keyFilePath.contains("encryption"));
- assertTrue(keyFilePath.endsWith(".key"));
- assertEquals(keyFilePath, configEncryption.getKeyFilePath());
+ invokeConfigureCliEnv(new FilePath(tempFolder.newFolder("encryption-test")), configEncryption);
+ assertEnv(envVars, JFROG_CLI_ENCRYPTION_KEY, configEncryption.getKeyFilePath());
}
@Test
diff --git a/src/test/java/io/jenkins/plugins/jfrog/integration/FreestyleJobITest.java b/src/test/java/io/jenkins/plugins/jfrog/integration/FreestyleJobITest.java
new file mode 100644
index 00000000..ffba9793
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/jfrog/integration/FreestyleJobITest.java
@@ -0,0 +1,133 @@
+package io.jenkins.plugins.jfrog.integration;
+
+import hudson.model.FreeStyleBuild;
+import hudson.model.FreeStyleProject;
+import hudson.model.Result;
+import hudson.tasks.Shell;
+import io.jenkins.plugins.jfrog.JfrogBuildInfoPublisher;
+import io.jenkins.plugins.jfrog.JfrogCliWrapper;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration tests for the Freestyle job extensions:
+ * - JfrogCliWrapper (Build Environment wrapper)
+ * - JfrogBuildInfoPublisher (Post-build action)
+ */
+class FreestyleJobITest extends PipelineTestBase {
+
+ /**
+ * JfrogCliWrapper should add JFROG_BINARY_PATH to the build environment.
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testWrapperSetsBinaryPath(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+ configureJfrogCliFromReleases(StringUtils.EMPTY, true);
+
+ FreeStyleProject project = jenkins.createFreeStyleProject("test-wrapper-path");
+ JfrogCliWrapper wrapper = new JfrogCliWrapper();
+ wrapper.setJfrogInstallation(JFROG_CLI_TOOL_NAME_1);
+ project.getBuildWrappersList().add(wrapper);
+
+ // Add a shell step that checks JFROG_BINARY_PATH is set
+ project.getBuildersList().add(
+ new Shell("echo \"Binary: $JFROG_BINARY_PATH\" && test -n \"$JFROG_BINARY_PATH\"")
+ );
+
+ FreeStyleBuild build = jenkins.buildAndAssertSuccess(project);
+ assertNotNull(build);
+ }
+
+ /**
+ * Without any installation selected, JfrogCliWrapper should still allow the build
+ * to proceed (uses system PATH).
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testWrapperNoInstallationUsesSysPath(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+
+ FreeStyleProject project = jenkins.createFreeStyleProject("test-wrapper-no-install");
+ JfrogCliWrapper wrapper = new JfrogCliWrapper();
+ // No installation set — uses system PATH
+ project.getBuildWrappersList().add(wrapper);
+ project.getBuildersList().add(new Shell("echo 'no installation, continuing'"));
+
+ FreeStyleBuild build = jenkins.buildAndAssertSuccess(project);
+ assertNotNull(build);
+ }
+
+ /**
+ * JfrogBuildInfoPublisher with publishOnlyOnSuccess=true must skip publish
+ * when the build has failed.
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testPublisherSkipsOnFailedBuild(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+
+ FreeStyleProject project = jenkins.createFreeStyleProject("test-publisher-skip");
+ // Deliberately fail the build
+ project.getBuildersList().add(new Shell("exit 1"));
+
+ JfrogBuildInfoPublisher publisher = new JfrogBuildInfoPublisher();
+ publisher.setPublishOnlyOnSuccess(true);
+ project.getPublishersList().add(publisher);
+
+ // Build should fail at the shell step; publisher should skip cleanly (no NPE or error from publisher)
+ FreeStyleBuild build = jenkins.buildAndAssertStatus(Result.FAILURE, project);
+ assertNotNull(build);
+ }
+
+ /**
+ * Freestyle flow: wrapper sets environment and build step successfully invokes the JFrog CLI.
+ * Verifies that JFROG_BINARY_PATH points to the installation directory and 'jf -v' succeeds.
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testFullFreestyleFlow(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+ configureJfrogCliFromReleases(StringUtils.EMPTY, true);
+
+ FreeStyleProject project = jenkins.createFreeStyleProject("test-freestyle-full");
+
+ // Build environment: set up JFrog CLI
+ JfrogCliWrapper wrapper = new JfrogCliWrapper();
+ wrapper.setJfrogInstallation(JFROG_CLI_TOOL_NAME_1);
+ project.getBuildWrappersList().add(wrapper);
+
+ // Build step: version check (JFROG_BINARY_PATH is the directory; append the binary name)
+ project.getBuildersList().add(new Shell("\"$JFROG_BINARY_PATH/jf\" -v"));
+
+ FreeStyleBuild build = jenkins.buildAndAssertSuccess(project);
+ assertNotNull(build);
+ }
+
+ /**
+ * JfrogCliWrapper.DescriptorImpl.isApplicable must return true for a regular
+ * FreeStyle project and false for a Matrix project (validated via reflection guard).
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testWrapperNotApplicableForMatrixProject(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+
+ JfrogCliWrapper.DescriptorImpl descriptor =
+ jenkins.jenkins.getDescriptorByType(JfrogCliWrapper.DescriptorImpl.class);
+ assertNotNull(descriptor);
+
+ // For a regular FreeStyle project, it must be applicable
+ FreeStyleProject freeStyle = jenkins.createFreeStyleProject("test-is-applicable");
+ assertTrue(descriptor.isApplicable(freeStyle));
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/jfrog/integration/ParallelInstallITest.java b/src/test/java/io/jenkins/plugins/jfrog/integration/ParallelInstallITest.java
new file mode 100644
index 00000000..3e5dbbe7
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/jfrog/integration/ParallelInstallITest.java
@@ -0,0 +1,72 @@
+package io.jenkins.plugins.jfrog.integration;
+
+import io.jenkins.plugins.jfrog.ReleasesInstaller;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+
+/**
+ * Integration tests verifying that parallel JFrog CLI installations do not corrupt
+ * the binary or produce race conditions.
+ */
+class ParallelInstallITest extends PipelineTestBase {
+
+ /**
+ * Two parallel pipeline stages that share the same JFrog CLI tool must both
+ * complete successfully without binary corruption.
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testParallelStagesSameToolVersion(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+ configureJfrogCliFromReleases(StringUtils.EMPTY, true);
+ runPipeline(jenkins, "parallel_install");
+ }
+
+ /**
+ * Two parallel pipeline stages each using a different CLI version must both
+ * complete and produce independent, valid binaries.
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testParallelStagesTwoToolVersions(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+ // Tool 1: latest CLI version
+ configureJfrogCliFromReleases(StringUtils.EMPTY, true);
+ // Tool 2: a known older specific version to verify independent parallel installs
+ ReleasesInstaller installer = new ReleasesInstaller();
+ installer.setVersion("2.29.2");
+ configureJfrogCliTool(JFROG_CLI_TOOL_NAME_2, installer, false);
+ runPipeline(jenkins, "parallel_install_two_tools");
+ }
+
+ /**
+ * Repeated installs of the same version should detect the valid cached binary
+ * and skip re-download (verified by both runs succeeding without error).
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testRepeatedInstallSkipsDownload(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+ configureJfrogCliFromReleases(StringUtils.EMPTY, true);
+ // First run installs
+ runPipeline(jenkins, "basic_version_command");
+ // Second run should hit the cache
+ runPipeline(jenkins, "basic_version_command");
+ }
+
+ /**
+ * Install from Artifactory in parallel: both stages must succeed.
+ *
+ * @param jenkins Jenkins instance injected automatically.
+ */
+ @Test
+ public void testParallelInstallFromArtifactory(JenkinsRule jenkins) throws Exception {
+ setupJenkins(jenkins);
+ configureJfrogCliFromArtifactory(JFROG_CLI_TOOL_NAME_1, TEST_CONFIGURED_SERVER_ID, getRepoKey(TestRepository.CLI_REMOTE_REPO), true);
+ runPipeline(jenkins, "parallel_install");
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java b/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java
index d6cb11b4..220056dc 100644
--- a/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java
+++ b/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java
@@ -54,8 +54,8 @@
public class PipelineTestBase {
private static long currentTime;
private static Artifactory artifactoryClient;
- private JenkinsRule jenkins;
- private Slave slave;
+ protected JenkinsRule jenkins;
+ protected Slave slave;
private static StringSubstitutor pipelineSubstitution;
private static final String SLAVE_LABEL = "TestSlave";
static final String PLATFORM_URL = System.getenv("JFROG_URL");
@@ -63,7 +63,7 @@ public class PipelineTestBase {
private static final String ARTIFACTORY_USERNAME = System.getenv("JFROG_USERNAME");
private static final String ARTIFACTORY_PASSWORD = System.getenv("JFROG_PASSWORD");
private static final String ACCESS_TOKEN = System.getenv("JFROG_ADMIN_TOKEN");
- private static final Path INTEGRATION_BASE_PATH = Paths.get(".").toAbsolutePath().normalize()
+ protected static final Path INTEGRATION_BASE_PATH = Paths.get(".").toAbsolutePath().normalize()
.resolve(Paths.get("src", "test", "resources", "integration"));
static final String JFROG_CLI_TOOL_NAME_1 = "jfrog-cli";
static final String JFROG_CLI_TOOL_NAME_2 = "jfrog-cli-2";
diff --git a/src/test/resources/integration/pipelines/parallel_install.pipeline b/src/test/resources/integration/pipelines/parallel_install.pipeline
new file mode 100644
index 00000000..ae31b73b
--- /dev/null
+++ b/src/test/resources/integration/pipelines/parallel_install.pipeline
@@ -0,0 +1,23 @@
+// Tests that parallel stages using the same JFrog CLI tool name do not corrupt installation
+pipeline {
+ agent any
+ tools {
+ jfrog '${JFROG_CLI_TOOL_NAME_1}'
+ }
+ stages {
+ stage('Parallel Safety') {
+ parallel {
+ stage('Task A') {
+ steps {
+ jf '-v'
+ }
+ }
+ stage('Task B') {
+ steps {
+ jf '-v'
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/test/resources/integration/pipelines/parallel_install_two_tools.pipeline b/src/test/resources/integration/pipelines/parallel_install_two_tools.pipeline
new file mode 100644
index 00000000..01f75da8
--- /dev/null
+++ b/src/test/resources/integration/pipelines/parallel_install_two_tools.pipeline
@@ -0,0 +1,18 @@
+// Tests parallel stages where each stage uses a different JFrog CLI version
+pipeline {
+ agent any
+ stages {
+ stage('Parallel Two Tools') {
+ parallel {
+ stage('Tool 1') {
+ tools { jfrog '${JFROG_CLI_TOOL_NAME_1}' }
+ steps { jf '-v' }
+ }
+ stage('Tool 2') {
+ tools { jfrog '${JFROG_CLI_TOOL_NAME_2}' }
+ steps { jf '-v' }
+ }
+ }
+ }
+ }
+}