This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new bfba9853dba1 CAMEL-23703: Add offline package-native validation CI for 
camel-launcher
bfba9853dba1 is described below

commit bfba9853dba1d03a0c3c67a41dbcadb40b54ae62
Author: Adriano Machado <[email protected]>
AuthorDate: Sat Jul 25 16:29:36 2026 -0400

    CAMEL-23703: Add offline package-native validation CI for camel-launcher
    
    Adds CI workflow to validate the Camel CLI launcher distribution the way
    real package managers consume it: POSIX validators for Homebrew and SDKMAN
    on macOS/Linux, installer unit tests on Windows. Includes install.sh/ps1
    hardening (manifest license-header tolerance, wget/tar error surfacing,
    PowerShell 7 compatibility), Homebrew formula audit fixes, and shared TLS
    cert optimization for test fixtures. Documentation updated with installer
    guide and upgrade-guide entries.
    
    Closes #25030
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .github/workflows/package-native-validation.yml    | 189 ++++++++
 docs/user-manual/modules/ROOT/nav.adoc             |   1 +
 .../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc    |   9 +
 .../ROOT/pages/camel-jbang-launcher-install.adoc   |  51 ++
 dsl/camel-jbang/camel-launcher/jreleaser.yml       |   6 +-
 dsl/camel-jbang/camel-launcher/pom.xml             |   6 +
 .../camel-launcher/src/install/install.ps1         |  92 +++-
 .../src/jreleaser/bin/camel-package.sh             |   8 +-
 .../src/jreleaser/bin/camel-validate.sh            | 296 ++++++++----
 .../src/jreleaser/bin/lib/assert-camel-cli.sh      |  55 +--
 .../launcher/PackageNativeValidationTest.java      | 525 +++++++++++++++++++++
 .../dsl/jbang/launcher/WebsiteInstallTest.java     |  25 +-
 .../jbang/launcher/WebsiteInstallerFixture.java    |  70 ++-
 13 files changed, 1175 insertions(+), 158 deletions(-)

diff --git a/.github/workflows/package-native-validation.yml 
b/.github/workflows/package-native-validation.yml
new file mode 100644
index 000000000000..c9a42e2295e2
--- /dev/null
+++ b/.github/workflows/package-native-validation.yml
@@ -0,0 +1,189 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+name: Package-native validation
+
+on:
+  push:
+    branches:
+      - main
+    paths:
+      - 'dsl/camel-jbang/camel-launcher/**'
+      - 'tooling/camel-exe/**'
+      - '.github/workflows/package-native-validation.yml'
+  pull_request:
+    branches:
+      - main
+    paths:
+      - 'dsl/camel-jbang/camel-launcher/**'
+      - 'tooling/camel-exe/**'
+      - '.github/workflows/package-native-validation.yml'
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+
+jobs:
+  # ── macOS / Linux (POSIX validators) ──────────────────────────────
+  posix-validator:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [macos-latest, ubuntu-latest]
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 
v7.0.1
+        with:
+          persist-credentials: false
+
+      # ubuntu-latest ships Homebrew (Linuxbrew) preinstalled but not on PATH 
(confirmed
+      # via actions/runner-images' Ubuntu readme: "Homebrew is preinstalled on 
image but
+      # not added to PATH", installed under /home/linuxbrew). Without this, 
`command -v
+      # brew` in camel-validate.sh's host-gate fails on Linux and the Homebrew 
validator
+      # silently SKIPs there, leaving macos-latest as the only leg that 
actually exercises
+      # it. macos-latest already has Homebrew on PATH by default, so this step 
is a no-op
+      # there.
+      - name: Add Linuxbrew to PATH (preinstalled but not on PATH on 
ubuntu-latest)
+        if: runner.os == 'Linux'
+        shell: bash
+        run: |
+          {
+            echo "/home/linuxbrew/.linuxbrew/bin"
+            echo "/home/linuxbrew/.linuxbrew/sbin"
+          } >> "$GITHUB_PATH"
+          {
+            echo "HOMEBREW_PREFIX=/home/linuxbrew/.linuxbrew"
+            echo "HOMEBREW_CELLAR=/home/linuxbrew/.linuxbrew/Cellar"
+            echo "HOMEBREW_REPOSITORY=/home/linuxbrew/.linuxbrew/Homebrew"
+          } >> "$GITHUB_ENV"
+
+      - name: Set up JDK 21
+        uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # 
v5.6.0
+        with:
+          distribution: 'temurin'
+          java-version: '21'
+          cache: 'maven'
+
+      # camel-repackager-maven-plugin builds the launcher's self-executing 
JAR. It is not in the
+      # -pl list below, so without this step Maven resolves it from the Apache 
snapshot repository.
+      # This workflow only triggers on dsl/camel-jbang/camel-launcher and 
tooling/camel-exe changes
+      # (see the `paths:` filters above), so the plugin's own sources are 
never what's under test here.
+      - name: Install camel-repackager-maven-plugin (build-time plugin, 
unresolvable from this reactor pass)
+        shell: bash
+        run: |
+          mvn -B -P apache-snapshots -ntp \
+            -pl tooling/maven/camel-repackager-maven-plugin install -DskipTests
+
+      - name: Build camel-launcher distribution
+        shell: bash
+        # No -am: build only the listed modules. camel-launcher's other 
in-repo dependencies
+        # (camel-jbang-core, its jbang plugins, camel-core, ...) resolve as 
the current version's
+        # published snapshot instead of being rebuilt from source, which is 
what previously turned
+        # this into a ~626-module reactor build. -P apache-snapshots is 
required for that resolution.
+        run: |
+          mvn -B -P apache-snapshots -ntp \
+            -pl buildingtools,dsl/camel-jbang/camel-launcher install 
-DskipTests
+
+      - name: Stage a synthetic release version for offline validation
+        shell: bash
+        run: |
+          cd dsl/camel-jbang/camel-launcher
+          REAL_VERSION=$(mvn -q -B -ntp 
org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate \
+            -Dexpression=project.version -DforceStdout)
+          TEST_VERSION="${REAL_VERSION%-SNAPSHOT}"
+          echo "TEST_VERSION=$TEST_VERSION" >> "$GITHUB_ENV"
+          cp -p "target/camel-launcher-$REAL_VERSION-bin.tar.gz" 
"target/camel-launcher-$TEST_VERSION-bin.tar.gz"
+          cp -p "target/camel-launcher-$REAL_VERSION-bin.zip" 
"target/camel-launcher-$TEST_VERSION-bin.zip"
+          # camel-package.sh prepare requires a WinGet release ZIP 
(camel-launcher-<v>-winget-bin.zip), but
+          # that artifact is only built by the -Dcamel.exe.build=true profile 
(native camel-exe), which this
+          # validation build does not run. The POSIX validators never consume 
the WinGet payload, so stage
+          # the already-built bin.zip under the WinGet name as an offline 
stand-in. Two names are needed:
+          # camel-package.sh's own existence check and cmp look for the 
stripped TEST_VERSION name (and
+          # CAMEL_PACKAGE_TEST_WINGET_REMOTE below points at the same bytes so 
the cmp passes without a
+          # network fetch to archive.apache.org), while JReleaser resolves the 
artifact from the real
+          # -SNAPSHOT POM version, so it needs the REAL_VERSION name too.
+          cp -p "target/camel-launcher-$REAL_VERSION-bin.zip" 
"target/camel-launcher-$TEST_VERSION-winget-bin.zip"
+          cp -p "target/camel-launcher-$REAL_VERSION-bin.zip" 
"target/camel-launcher-$REAL_VERSION-winget-bin.zip"
+
+      - name: Prepare packages (stable, offline, synthetic release version)
+        shell: bash
+        run: |
+          cd dsl/camel-jbang/camel-launcher
+          CAMEL_PACKAGE_TEST_MODE=true 
CAMEL_PACKAGE_TEST_VERSION="$TEST_VERSION" \
+            
CAMEL_PACKAGE_TEST_WINGET_REMOTE="target/camel-launcher-$TEST_VERSION-winget-bin.zip"
 \
+            bash src/jreleaser/bin/camel-package.sh prepare --channel stable
+
+      - name: Validate local archive + Homebrew + SDKMAN (host-gated)
+        shell: bash
+        run: |
+          cd dsl/camel-jbang/camel-launcher
+          CAMEL_PACKAGE_TEST_MODE=true 
CAMEL_PACKAGE_TEST_VERSION="$TEST_VERSION" \
+            bash src/jreleaser/bin/camel-validate.sh all
+
+      - name: Run POSIX validator unit tests
+        shell: bash
+        # WebsiteInstallTest's PosixShell suite only gets targeted CI coverage 
today as a side
+        # effect of the generic incremental-build check noticing a direct 
change under
+        # dsl/camel-jbang/camel-launcher; a PR that only touches an upstream 
dependency wouldn't
+        # trigger it. Run it here explicitly so this dedicated validation 
workflow covers it too.
+        run: |
+          cd dsl/camel-jbang/camel-launcher
+          mvn -B -ntp test -pl . 
-Dtest=PackageNativeValidationTest,WebsiteInstallTest
+
+  # ── Windows (install.ps1 unit tests) ──────────────────────────────
+  windows-validator:
+    runs-on: windows-latest
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 
v7.0.1
+        with:
+          persist-credentials: false
+
+      - name: Set up JDK 21
+        uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # 
v5.6.0
+        with:
+          distribution: 'temurin'
+          java-version: '21'
+          cache: 'maven'
+
+      # camel-repackager-maven-plugin builds the launcher's self-executing 
JAR. It is not in the
+      # -pl list below, so without this step Maven resolves it from the Apache 
snapshot repository.
+      # This workflow only triggers on dsl/camel-jbang/camel-launcher and 
tooling/camel-exe changes
+      # (see the `paths:` filters above), so the plugin's own sources are 
never what's under test here.
+      - name: Install camel-repackager-maven-plugin (build-time plugin, 
unresolvable from this reactor pass)
+        shell: bash
+        run: |
+          mvn -B -P apache-snapshots -ntp \
+            -pl tooling/maven/camel-repackager-maven-plugin install -DskipTests
+
+      - name: Build camel-launcher module
+        shell: bash
+        # No -am: build only the listed modules. camel-launcher's other 
in-repo dependencies
+        # (camel-jbang-core, its jbang plugins, camel-core, ...) resolve as 
the current version's
+        # published snapshot instead of being rebuilt from source, which is 
what previously turned
+        # this into a ~626-module reactor build. -P apache-snapshots is 
required for that resolution.
+        run: |
+          mvn -B -P apache-snapshots -ntp \
+            -pl buildingtools,dsl/camel-jbang/camel-launcher install 
-DskipTests
+
+      - name: Run Windows installer unit tests
+        shell: bash
+        run: |
+          cd dsl/camel-jbang/camel-launcher
+          mvn -B -ntp test -pl . -Dtest=WebsiteInstallTest
diff --git a/docs/user-manual/modules/ROOT/nav.adoc 
b/docs/user-manual/modules/ROOT/nav.adoc
index 1b8a556c336f..433b538a6dba 100644
--- a/docs/user-manual/modules/ROOT/nav.adoc
+++ b/docs/user-manual/modules/ROOT/nav.adoc
@@ -8,6 +8,7 @@
 ** xref:camel-jbang.adoc[Camel CLI]
 *** xref:jbang-commands/camel-jbang-commands.adoc[Camel CLI Command Reference]
 *** xref:camel-jbang-launcher.adoc[Camel CLI Launcher]
+*** xref:camel-jbang-launcher-install.adoc[Installing the Camel CLI Launcher]
 *** xref:camel-jbang-kubernetes.adoc[Camel Kubernetes Plugin]
 *** xref:camel-jbang-test.adoc[Camel Testing Plugin]
 *** xref:camel-jbang-tui.adoc[Camel TUI]
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 48f8e6d52479..82aacf6f0e42 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -178,6 +178,15 @@ Two new commands are available in the 
xref:camel-jbang-launcher.adoc[Camel CLI L
 
 See xref:camel-jbang-launcher-install.adoc[Installing the Camel CLI Launcher] 
for full details.
 
+==== Camel CLI package-native validation (maintainers)
+
+The `camel-launcher` module adds an offline package-native validator
+(`src/jreleaser/bin/camel-validate.sh`) that installs each generated package, 
asserts
+`camel version` and an offline `camel init`, then uninstalls. Each validator 
self-skips
+when its package manager is absent, so the same entry point runs on macOS, 
Linux, and
+Windows x64 CI. Validation performs no remote publication and calls no SDKMAN 
Vendor
+API; that is the `publish` workflow.
+
 === camel-langchain4j-agent
 
 The `Agent.chat()` method return type has changed from `String` to 
`Result<String>` (from `dev.langchain4j.service.Result`).
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc
index 3054cc2e16b5..7aae2600a640 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc
@@ -25,6 +25,30 @@ paths, `../` traversal, escaping symlinks/reparse points, or 
more than one top-l
 staged launcher is run once to confirm a Java 17+ runtime can be discovered; 
if that check fails,
 the previously active installation, if any, is left untouched and the 
installer exits nonzero.
 
+== Installing a specific version
+
+On Linux and macOS, pass the argument through to the piped shell with `-s --`:
+
+[source,bash]
+----
+curl -fsSL https://camel.apache.org/install.sh | sh -s -- --version 4.22.0
+----
+
+On Windows, download the script first, then run it with the parameter:
+
+[source,powershell]
+----
+irm https://camel.apache.org/install.ps1 -OutFile install.ps1
+.\install.ps1 -Version 4.22.0
+----
+
+== Requirements
+
+* A Java 17+ runtime available at install and run time. The launcher locates 
Java from
+  `JAVACMD`, `JAVA_HOME`, or `PATH`.
+* Linux and macOS: `curl` or `wget`; `tar`; and one of `sha256sum`, `shasum`, 
or `openssl`.
+* Windows: PowerShell 5.1 or later (`Invoke-WebRequest`, `Expand-Archive`, 
`Get-FileHash`).
+
 == Where the CLI is installed
 
 Installation is always per-user and never requires elevation or `sudo`:
@@ -103,3 +127,30 @@ shell currently runs; the others are unused but still 
present.
 
 `camel doctor` exits non-zero when more than one installation is found, so the 
check is scriptable
 in CI.
+
+== Uninstalling
+
+The installers do not modify shell profiles or the machine `PATH`, so removal 
is a matter of
+deleting the per-user files:
+
+On Linux and macOS:
+
+[source,bash]
+----
+rm -f "$HOME/.local/bin/camel"
+rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli"
+----
+
+On Windows, remove the install directory and drop its bin directory from your 
user `PATH`:
+
+[source,powershell]
+----
+Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Apache Camel"
+----
+
+== See also
+
+* xref:camel-jbang-launcher.adoc[Camel CLI Launcher] — the distribution the 
installers install.
+* xref:camel-jbang-installation.adoc[Camel CLI - Installation Options] — 
installing via JBang,
+  version pinning, the Camel Wrapper, and the container image.
+* xref:camel-jbang-getting-started.adoc[Getting Started] — the standard 
JBang-based install.
diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml 
b/dsl/camel-jbang/camel-launcher/jreleaser.yml
index da41cb2e610a..8f94c67809f3 100644
--- a/dsl/camel-jbang/camel-launcher/jreleaser.yml
+++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml
@@ -75,7 +75,7 @@
 
 project:
   name: camel-cli
-  description: Apache Camel CLI
+  description: CLI for building and running Apache Camel integration routes
   links:
     homepage: https://camel.apache.org/
   authors:
@@ -138,8 +138,8 @@ distributions:
         deprecateDate: "{{ Env.CAMEL_PKG_BREW_DEPRECATE_DATE }}"
         disableDate: "{{ Env.CAMEL_PKG_BREW_DISABLE_DATE }}"
       livecheck:
-        - 'url 
"https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/maven-metadata.xml";'
-        - 'regex(/{{{ Env.CAMEL_PKG_BREW_LIVECHECK_REGEX }}}/i)'
+        - 'url 
"https://search.maven.org/remotecontent?filepath=org/apache/camel/camel-launcher/maven-metadata.xml";'
+        - '{{{ Env.CAMEL_PKG_BREW_LIVECHECK_REGEX }}}'
     sdkman:
       active: RELEASE
       candidate: camel
diff --git a/dsl/camel-jbang/camel-launcher/pom.xml 
b/dsl/camel-jbang/camel-launcher/pom.xml
index a9a6c9e24c27..a8cdcb6db367 100644
--- a/dsl/camel-jbang/camel-launcher/pom.xml
+++ b/dsl/camel-jbang/camel-launcher/pom.xml
@@ -40,6 +40,12 @@
         <supportLevel>Stable</supportLevel>
         <camel-prepare-component>false</camel-prepare-component>
 
+        <!-- WebsiteInstallTest's Windows PowerShell suite spawns install.ps1 
through a fresh
+             powershell.exe per test (~25 tests); on GitHub's windows-latest 
runners each invocation
+             costs ~24s of PowerShell startup/Defender-scan overhead, so the 
class alone runs past the
+             default 600s fork budget even though no single test hangs. -->
+        <camel.surefire.forkTimeout>1200</camel.surefire.forkTimeout>
+
         <!-- Dependency versions -->
         <camel-kamelets-version>4.20.0</camel-kamelets-version>
         <maven-version>3.9.16</maven-version>
diff --git a/dsl/camel-jbang/camel-launcher/src/install/install.ps1 
b/dsl/camel-jbang/camel-launcher/src/install/install.ps1
index 129323b5382a..552c426a9370 100644
--- a/dsl/camel-jbang/camel-launcher/src/install/install.ps1
+++ b/dsl/camel-jbang/camel-launcher/src/install/install.ps1
@@ -49,33 +49,75 @@ function Test-ValidSha256 {
     }
 }
 
+$UseSkipCertificateCheck = $false
 if ($CaCertPath) {
     # Test seam only: trusts the loopback fixture's self-signed CA for this 
process without touching
     # the real Windows certificate store. Production installs never set 
CAMEL_INSTALL_CA_CERT.
-    $installerCaCert = New-Object 
System.Security.Cryptography.X509Certificates.X509Certificate2($CaCertPath)
-    [System.Net.ServicePointManager]::SecurityProtocol = 
[System.Net.SecurityProtocolType]::Tls12
-    [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {
-        param($sender, $certificate, $chain, $sslPolicyErrors)
-        $verifyChain = New-Object 
System.Security.Cryptography.X509Certificates.X509Chain
-        $verifyChain.ChainPolicy.ExtraStore.Add($installerCaCert) | Out-Null
-        $verifyChain.ChainPolicy.RevocationMode = 
[System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
-        $verifyChain.ChainPolicy.VerificationFlags = 
[System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority
-        $leaf = New-Object 
System.Security.Cryptography.X509Certificates.X509Certificate2($certificate)
-        if (-not $verifyChain.Build($leaf)) {
-            return $false
+    if ($PSVersionTable.PSEdition -eq 'Core') {
+        # PowerShell 7+'s Invoke-WebRequest runs on HttpClient, which never 
consults
+        # ServicePointManager.ServerCertificateValidationCallback (a Windows 
PowerShell/.NET Framework-
+        # only hook) - it's silently ignored, so the callback below never 
rejects a bad cert either.
+        # Pin trust via -SkipCertificateCheck on the call instead.
+        $UseSkipCertificateCheck = $true
+    } else {
+        $installerCaCert = New-Object 
System.Security.Cryptography.X509Certificates.X509Certificate2($CaCertPath)
+        [System.Net.ServicePointManager]::SecurityProtocol = 
[System.Net.SecurityProtocolType]::Tls12
+
+        # ServerCertificateValidationCallback is invoked by the CLR on 
whatever thread performs the TLS
+        # handshake - for HttpWebRequest that is often a 
thread-pool/IO-completion-port thread with no
+        # PowerShell Runspace bound to it. A scriptblock delegate then fails 
intermittently with "There is
+        # no Runspace available to run scripts in this thread." A compiled 
type has no such dependency, so
+        # validate via Add-Type instead of a scriptblock.
+        if (-not ('CamelInstallCertValidator' -as [type])) {
+            Add-Type -TypeDefinition @"
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+
+public static class CamelInstallCertValidator {
+    public static X509Certificate2 CaCert;
+
+    public static bool Validate(object sender, X509Certificate certificate, 
X509Chain chain, SslPolicyErrors sslPolicyErrors) {
+        using (var verifyChain = new X509Chain()) {
+            verifyChain.ChainPolicy.ExtraStore.Add(CaCert);
+            verifyChain.ChainPolicy.RevocationMode = 
X509RevocationMode.NoCheck;
+            verifyChain.ChainPolicy.VerificationFlags = 
X509VerificationFlags.AllowUnknownCertificateAuthority;
+            var leaf = new X509Certificate2(certificate);
+            if (!verifyChain.Build(leaf)) {
+                return false;
+            }
+            var root = 
verifyChain.ChainElements[verifyChain.ChainElements.Count - 1].Certificate;
+            return root.Thumbprint == CaCert.Thumbprint;
+        }
+    }
+}
+"@
         }
-        $root = $verifyChain.ChainElements[$verifyChain.ChainElements.Count - 
1].Certificate
-        return $root.Thumbprint -eq $installerCaCert.Thumbprint
-    }.GetNewClosure()
+        [CamelInstallCertValidator]::CaCert = $installerCaCert
+        # Windows PowerShell 5.1 cannot implicitly convert a bare method 
reference (PSMethod) to a
+        # custom delegate type - "Cannot convert ... value of type 
System.Management.Automation.PSMethod
+        # to type System.Net.Security.RemoteCertificateValidationCallback." 
Build the delegate explicitly.
+        [System.Net.ServicePointManager]::ServerCertificateValidationCallback 
= [System.Delegate]::CreateDelegate(
+            [System.Net.Security.RemoteCertificateValidationCallback],
+            [CamelInstallCertValidator],
+            'Validate')
+    }
 }
 
 # Downloads $Url to $OutFile; used for both the manifest and archive fetches.
 function Save-RemoteFile {
     param([string] $Url, [string] $OutFile)
     try {
-        Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing | 
Out-Null
+        if ($UseSkipCertificateCheck) {
+            Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing 
-SkipCertificateCheck | Out-Null
+        } else {
+            Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing | 
Out-Null
+        }
     } catch {
-        Fail "failed to download $Url"
+        $detail = $_.Exception.Message
+        if ($_.Exception.InnerException) {
+            $detail = "$detail -- $($_.Exception.InnerException.Message)"
+        }
+        Fail "failed to download $Url ($detail)"
     }
 }
 
@@ -157,7 +199,11 @@ function Test-ArchiveEntry {
             if ($segments -contains '..') {
                 Fail "archive contains a path traversal entry: $name"
             }
-            $unixMode = ([uint32]$entry.ExternalAttributes -shr 16) -band 
0xF000
+            # ExternalAttributes is a signed Int32 whose upper 16 bits carry 
the Unix mode; any entry
+            # with mode bits set (e.g. 0100644, universal for a POSIX-built 
archive) overflows into
+            # negative range there. [uint32] is a checked cast that throws on 
a negative input, so mask
+            # to the low 32 bits as Int64 first to reinterpret the same bit 
pattern unsigned.
+            $unixMode = ([uint32]($entry.ExternalAttributes -band 0xFFFFFFFFL) 
-shr 16) -band 0xF000
             if ($unixMode -eq 0xA000) {
                 Fail "archive contains a symbolic link or reparse point entry, 
which is not allowed"
             }
@@ -205,8 +251,13 @@ function Set-CamelShim {
     Move-Item -LiteralPath $StagedRoot -Destination $targetDir
 
     New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
-    $launcherPath = Join-Path $targetDir 'bin\camel.bat'
-    $shimContent = "@echo off`r`ncall `"$launcherPath`" %*`r`nexit /b 
%ERRORLEVEL%`r`n"
+    # Resolve the launcher relative to the shim's own location (%~dp0 = 
<InstallRoot>\bin\) rather than
+    # embedding the absolute install path as literal text. cmd.exe parses 
batch-file bytes in the OEM
+    # code page, so a non-ASCII install path (e.g. a user profile containing 
'über') written into the
+    # file as UTF-8 would be misread and 'call' would fail with "The system 
cannot find the path
+    # specified." %~dp0 supplies that path as a runtime value instead, which 
cmd handles correctly - the
+    # same self-location pattern camel.bat itself uses. Only the version 
(validated ASCII) is embedded.
+    $shimContent = "@echo off`r`ncall 
`"%~dp0..\cli\versions\$Version\bin\camel.bat`" %*`r`nexit /b %ERRORLEVEL%`r`n"
     $tempShim = Join-Path $BinDir ".camel.$PID.tmp.cmd"
     # Write without a BOM: Windows PowerShell 5.1's 'Set-Content -Encoding 
UTF8' prepends one, which
     # cmd.exe treats as part of the first line, breaking '@echo off' and 
emitting a stray error.
@@ -311,4 +362,7 @@ try {
     if (Test-Path -LiteralPath $stagingRoot) {
         Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction 
SilentlyContinue
     }
+    # ServerCertificateValidationCallback is process-wide static state; clear 
it even though this
+    # process is about to exit, in case a caller dot-sources install.ps1 into 
a longer-lived session.
+    [System.Net.ServicePointManager]::ServerCertificateValidationCallback = 
$null
 }
diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh 
b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh
index 8057a667ab54..4ec875618ca3 100755
--- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh
+++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh
@@ -111,7 +111,11 @@ if [ "$CHANNEL" = "stable" ]; then
   WEBSITE_LATEST="true"
   # The maven-metadata.xml <release> tag always reflects the newest published 
release across every
   # line, which is exactly "latest stable" for this formula - no line-prefix 
filtering needed.
-  CAMEL_PKG_BREW_LIVECHECK_REGEX='<release>([0-9]+\.[0-9]+\.[0-9]+)<\/release>'
+  # %r{} (not /.../ ) per Homebrew's Style/RegexpLiteral cop, which the 
pattern's escaped "/"
+  # would otherwise trip; wrapped here (not in jreleaser.yml) because 
livecheck's Mustache tag
+  # must stay a clean, unpadded {{{ }}} - butting a literal %r{ or trailing 
}i) directly against
+  # the triple-mustache braces confuses the tag scanner and silently drops the 
substitution.
+  
CAMEL_PKG_BREW_LIVECHECK_REGEX='regex(%r{<release>([0-9]+\.[0-9]+\.[0-9]+)</release>}i)'
   [ -n "$LTS_LINE" ] && BREW_LTS_FORMULA="apache-camel@$LTS_LINE"
 else
   # Homebrew's own versioned-formula convention names the *file* 
"[email protected]" but the
@@ -135,7 +139,7 @@ else
   # literally, not "any character") or it would report the project's overall 
latest release
   # instead of this line's own latest patch.
   lts_line_escaped=$(echo "$LTS_LINE" | sed 's/\./\\./g')
-  
CAMEL_PKG_BREW_LIVECHECK_REGEX="<version>(${lts_line_escaped}\.[0-9]+)<\/version>"
+  
CAMEL_PKG_BREW_LIVECHECK_REGEX="regex(%r{<version>(${lts_line_escaped}\.[0-9]+)</version>}i)"
   # deprecate! fires 6 months before the line's own documented support end; 
disable! fires on the
   # support-end date itself. supported_ends was already resolved and validated 
(non-empty, a real
   # entry in supported-lts.yml) by the --lts-line validation earlier in this 
script - re-used here
diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-validate.sh 
b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-validate.sh
index 0d13879bb472..1566fdba0c2c 100755
--- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-validate.sh
+++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-validate.sh
@@ -23,22 +23,38 @@
 #   camel-validate.sh <command> [options]
 #
 # Commands:
-#   all      Run all available validators (homebrew, sdkman). Each is 
host-gated.
+#   all      Run every validator: local (always, if the archive is present), 
plus the
+#            host-gated homebrew and sdkman validators.
+#   local    Validate this build's own archive directly (no package manager 
involved)
 #   homebrew Validate via Homebrew (audit + install + version + init + 
uninstall)
-#   sdkman   Validate via SDKMAN (descriptor check + offline archive + version 
+ init)
+#   sdkman   Validate via SDKMAN (descriptor + archive checks only; 
version/init are not
+#            exercised offline - see validate_sdkman)
 #   help     Show usage
 #
 # Host-gating:
-#   Each validator checks for its package manager. If absent, prints "SKIP:" 
and exits 0.
-#   If present but a failure occurs, exits nonzero.
+#   The homebrew and sdkman validators check for their package manager and 
print "SKIP:"
+#   (exit 0) if it is absent. The local validator is not 
package-manager-gated; it runs
+#   whenever the staged archive exists. Any genuine validation failure exits 
nonzero.
 # ============================================================================
 
 set -eu
 
-SCRIPT_DIR=`CDPATH= cd -- "$(dirname -- "$0")" && pwd`
-MODULE_DIR=`CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd`
+SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+MODULE_DIR=$(CDPATH='' cd -- "$SCRIPT_DIR/../../.." && pwd)
 LIB_DIR="$SCRIPT_DIR/lib"
 
+# Prepare/build outputs live under target/. Overridable in test mode only, so 
unit tests can
+# point the archive validators at a synthetic staging dir instead of the real 
target/.
+if [ -n "${CAMEL_VALIDATE_TARGET_DIR:-}" ]; then
+    if [ "${CAMEL_PACKAGE_TEST_MODE:-}" != "true" ]; then
+        echo "Error: CAMEL_VALIDATE_TARGET_DIR requires 
CAMEL_PACKAGE_TEST_MODE=true." >&2
+        exit 2
+    fi
+    TARGET_DIR="$CAMEL_VALIDATE_TARGET_DIR"
+else
+    TARGET_DIR="$MODULE_DIR/target"
+fi
+
 usage() {
     cat <<EOF
 Usage: camel-validate.sh <command> [options]
@@ -48,7 +64,8 @@ Commands:
              validators are host-gated; "local" always runs if the archive is 
present.
   local      Validate this build's own archive directly (no package manager 
involved)
   homebrew   Validate via Homebrew (audit + install + version + init + 
uninstall)
-  sdkman     Validate via SDKMAN (descriptor check + offline archive + version 
+ init)
+  sdkman     Validate via SDKMAN (descriptor + archive checks only; 
version/init are not
+             exercised offline)
   help       Show this help message
 
 Options:
@@ -88,12 +105,22 @@ done
 
 case "$COMMAND" in help) usage ;; esac
 
+case "$CHANNEL" in
+  stable|lts) ;;
+  *) echo "Error: --channel must be 'stable' or 'lts' (got '$CHANNEL')." >&2; 
usage ;;
+esac
+
+if [ "$CHANNEL" = "lts" ] && [ -z "$LTS_LINE" ]; then
+    echo "Error: --channel lts requires --lts-line X.Y." >&2
+    usage
+fi
+
 # ---------- sourcing ----------
 
 . "$LIB_DIR/assert-camel-cli.sh"
 
 # ---------- shared temp workspace ----------
-# Used by every validator (homebrew, sdkman); created once and cleaned on 
exit. Kept at script
+# Used by every validator (local, homebrew, sdkman); created once and cleaned 
on exit. Kept at script
 # scope (not local to a validator) so both functions and the EXIT trap can see 
it under 'set -u'.
 TMPDIR_BASE=$(mktemp -d)
 
@@ -140,6 +167,17 @@ formula_name() {
     esac
 }
 
+# Portable sha256 of a file (stdout = bare hex digest). Mirrors install.sh's 
fallback chain.
+sha256_of() {
+    if command -v sha256sum >/dev/null 2>&1; then
+        sha256sum "$1" | awk '{print $1}'
+    elif command -v shasum >/dev/null 2>&1; then
+        shasum -a 256 "$1" | awk '{print $1}'
+    else
+        openssl dgst -sha256 "$1" | awk '{print $NF}'
+    fi
+}
+
 # ============================================================
 # Homebrew validator
 # ============================================================
@@ -159,7 +197,7 @@ validate_homebrew() {
     #   target/jreleaser/package/camel-cli/brew/Formula/<formula-name>.rb
     # (empirically verified with a real, non-stubbed jreleaser:package dry 
run; there is no
     # target/jreleaser/distributions/ directory at all in this JReleaser 
version).
-    
formula_file="$MODULE_DIR/target/jreleaser/package/camel-cli/brew/Formula/${fmla}.rb"
+    
formula_file="$TARGET_DIR/jreleaser/package/camel-cli/brew/Formula/${fmla}.rb"
 
     if [ ! -f "$formula_file" ]; then
         echo "SKIP: homebrew formula not found: $formula_file (did prepare 
run?)"
@@ -186,28 +224,68 @@ validate_homebrew() {
     BREW_TAP_NAME="$tap_name"
 
     tap_dir=$(brew --repository "$tap_name")
-    cp -p "$formula_file" "$tap_dir/Formula/${fmla}.rb"
-    chmod a+r "$tap_dir/Formula/${fmla}.rb"
+    if ! cp -p "$formula_file" "$tap_dir/Formula/${fmla}.rb"; then
+        echo "FAIL: could not copy the generated formula into the validation 
tap"
+        return 1
+    fi
+    # 644, not `a+r`: a world-writable formula file trips `brew audit --strict`
+    # (FormulaAudit/Files "Incorrect file permissions (777)"), which would be 
a false
+    # failure of the audit gate below caused by the tap copy itself, not by 
the formula.
+    chmod 644 "$tap_dir/Formula/${fmla}.rb"
 
     # Step 1: Homebrew style + audit, referenced by tap-qualified name (not a 
path).
-    local audit_output=""
-    audit_output=$(HOMEBREW_NO_AUTO_UPDATE=1 brew style --fix 
"$tap_name/$fmla" 2>&1 || true)
+    # `brew style --fix` only normalizes formatting; its output is 
informational.
+    local style_output=""
+    style_output=$(HOMEBREW_NO_AUTO_UPDATE=1 brew style --fix 
"$tap_name/$fmla" 2>&1 || true)
     echo "INFO: homebrew style output:"
+    echo "$style_output"
+
+    # `brew audit --strict` is a real gate: a nonzero exit means a defect in 
the generated
+    # formula and must fail validation, not warn past it. --online is 
deliberately omitted so
+    # audit stays offline and never fails merely because the (test-mode, 
unpublished) download
+    # URL is unreachable - only the offline structural/style/naming checks run 
here.
+    local audit_output audit_rc=0
+    audit_output=$(HOMEBREW_NO_AUTO_UPDATE=1 brew audit --strict 
"$tap_name/$fmla" 2>&1) || audit_rc=$?
+    # Always echo the audit output (even on success) so the log shows the gate 
actually ran and
+    # surfaces any advisory lines that did not rise to a nonzero exit.
+    echo "INFO: homebrew audit --strict output:"
     echo "$audit_output"
-
-    # --new (not just --strict): homebrew-core enforces additional checks 
specific to a first-time
-    # formula submission - the bar apache-camel needs to clear before 
homebrew-core will accept it.
-    # Plain --strict is the bar for an *already-accepted* formula being 
updated, which understates
-    # what's needed for this PR.
-    audit_output=$(HOMEBREW_NO_AUTO_UPDATE=1 brew audit --new --strict 
"$tap_name/$fmla" 2>&1 || true)
-    if echo "$audit_output" | grep -Eqi "Error|error:"; then
-        echo "WARN: homebrew audit reported issues:"
-        echo "$audit_output"
+    if [ "$audit_rc" -ne 0 ]; then
+        echo "FAIL: homebrew audit --strict reported issues (exit $audit_rc)"
+        rc=1
     else
         echo "PASS: homebrew style/audit passed"
     fi
 
-    # Step 2: Install the formula from the tap
+    # Step 2: Install the formula from the tap.
+    #
+    # In test mode the formula's download URL points at a Maven coordinate for 
a version that
+    # is not published yet (in CI the real POM version is a -SNAPSHOT, and 
JReleaser renders
+    # the URL from that POM version - Central never hosts SNAPSHOTs). Rather 
than let `brew
+    # install` fail to download and skip the whole 
install/version/init/uninstall chain,
+    # rewrite the tapped formula's url to a file:// path for this build's own 
staged archive
+    # and recompute its sha256, so brew genuinely downloads, verifies, 
extracts and installs
+    # the real binary this build produced - fully offline and deterministic. 
Real release runs
+    # (not test mode) keep the published Central URL untouched. This runs 
after `brew audit`
+    # above, so audit still validates the real published-style formula, not 
the file:// one.
+    if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ]; then
+        local url_basename staged_archive staged_sha
+        url_basename=$(sed -n 's/^[[:space:]]*url "[^"]*\/\([^/"]*\)"/\1/p' 
"$tap_dir/Formula/${fmla}.rb" | head -n1)
+        staged_archive="$TARGET_DIR/$url_basename"
+        if [ -z "$url_basename" ] || [ ! -f "$staged_archive" ]; then
+            echo "FAIL: test-mode formula rewrite could not locate the staged 
archive for '$url_basename' under $TARGET_DIR"
+            return 1
+        fi
+        staged_sha=$(sha256_of "$staged_archive")
+        if ! sed -e "s|^\([[:space:]]*\)url \"[^\"]*\"|\1url 
\"file://$staged_archive\"|" \
+                 -e "s|^\([[:space:]]*\)sha256 \"[^\"]*\"|\1sha256 
\"$staged_sha\"|" \
+                 "$tap_dir/Formula/${fmla}.rb" > 
"$tap_dir/Formula/${fmla}.rb.new"; then
+            echo "FAIL: could not rewrite the tapped formula for offline 
install"
+            return 1
+        fi
+        mv -f "$tap_dir/Formula/${fmla}.rb.new" "$tap_dir/Formula/${fmla}.rb"
+    fi
+
     local BREW_HOME="$TMPDIR_BASE/brew-home"
     mkdir -p "$BREW_HOME"
     local brew_output install_rc=0
@@ -219,21 +297,23 @@ validate_homebrew() {
         if echo "$brew_output" | grep -Eqi "no suitable java"; then
             # A missing/incompatible Java dependency is a host-resolution 
limitation, not a
             # defect in the generated formula; nothing was installed, so there 
is nothing
-            # left to validate.
+            # left to validate. Still propagate $rc: an audit failure recorded 
above is a real
+            # defect and must not be masked just because install itself 
couldn't proceed.
             echo "SKIP: homebrew install could not resolve a Java dependency 
on this host:"
             echo "$brew_output"
-            return 0
+            return "$rc"
         fi
-        if echo "$brew_output" | grep -Eqi "Failed to download resource|curl: 
\("; then
-            # The formula's download URL always points at the real Maven 
Central release
-            # coordinates (see jreleaser.yml); for a local/offline or 
synthetic-version
-            # validation run, no artifact was ever actually published there, 
so the fetch
-            # can never succeed here regardless of whether the generated 
formula is
-            # correct. Host/environment limitation, not a defect in the 
formula itself;
-            # empirically confirmed on this machine.
-            echo "SKIP: homebrew install could not download the release 
artifact (not published for this version, expected for local/offline 
validation):"
+        if [ "${CAMEL_PACKAGE_TEST_MODE:-}" != "true" ] \
+                && echo "$brew_output" | grep -Eqi "Failed to download 
resource|curl: \("; then
+            # Non-test (real release) runs only: if the release artifact is 
not yet published at
+            # its Central coordinate, the fetch cannot succeed regardless of 
whether the formula
+            # is correct, so treat it as an environment limitation. In test 
mode the url was
+            # rewritten to a local file:// archive above, so a download 
failure there is a
+            # genuine defect and falls through to FAIL below. Propagate $rc 
for the same reason
+            # as the Java-dependency SKIP above: an already-recorded audit 
failure must survive.
+            echo "SKIP: homebrew install could not download the release 
artifact (not published yet, expected for an unpublished release):"
             echo "$brew_output"
-            return 0
+            return "$rc"
         fi
         echo "FAIL: homebrew install failed (exit $install_rc):"
         echo "$brew_output"
@@ -245,16 +325,18 @@ validate_homebrew() {
     # is not proof the executable actually works, so a 
missing/empty/mismatched result here
     # is a real failure, not something to warn past.
     #
-    # Expected version comes from the formula's own `version` line, not 
$RESOLVED_VERSION:
-    # camel-package.sh's test-mode-only hack patches url/version/sha256 to a 
real,
-    # already-published camel-launcher release so `brew install` can genuinely 
download
-    # and verify it (see the comment in camel-package.sh), so what actually 
gets installed
-    # here may differ from this build's own synthetic version. Reading it back 
out of the
-    # tapped formula keeps this assertion correct in both that case and real 
releases
-    # (where it's simply $RESOLVED_VERSION again).
+    # Expected version comes from the formula's own `version` line, not 
$RESOLVED_VERSION.
+    # JReleaser renders the formula version from the real POM version, which 
in test mode is
+    # the -SNAPSHOT that the offline file:// install above actually installs; 
that differs from
+    # $RESOLVED_VERSION (which strips -SNAPSHOT for local-archive lookups). 
Reading the expected
+    # value back out of the tapped formula keeps this assertion correct in 
both the test-mode
+    # case and a real release (where the formula version is $RESOLVED_VERSION 
anyway).
     local expected_version
     expected_version=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' 
"$tap_dir/Formula/${fmla}.rb" | head -n1)
-    [ -n "$expected_version" ] || expected_version="$RESOLVED_VERSION"
+    if [ -z "$expected_version" ]; then
+        echo "WARN: could not read 'version' from tapped formula ${fmla}.rb, 
falling back to \$RESOLVED_VERSION ($RESOLVED_VERSION)"
+        expected_version="$RESOLVED_VERSION"
+    fi
 
     local camv_output=""
     if ! command -v camel >/dev/null 2>&1; then
@@ -263,7 +345,9 @@ validate_homebrew() {
     else
         camv_output=$(camel --version 2>/dev/null) || true
         if [ -z "$camv_output" ]; then
-            echo "FAIL: 'camel --version' returned empty output after install"
+            local camv_err=""
+            camv_err=$(camel --version 2>&1 >/dev/null) || true
+            echo "FAIL: 'camel --version' returned empty output after 
install${camv_err:+ (stderr: $camv_err)}"
             rc=1
         else
             assert_camel_version "$camv_output" "$expected_version" && \
@@ -277,30 +361,43 @@ validate_homebrew() {
     if ! command -v camel >/dev/null 2>&1; then
         echo "FAIL: camel not available for init test (install reported 
success but binary is missing)"
         rc=1
-    elif ! (cd "$init_dir" && camel init hello.java >/dev/null 2>&1); then
-        echo "FAIL: camel init failed after a successful homebrew install"
-        rc=1
     else
-        assert_init_content "$init_dir" "hello.java" || rc=1
+        local init_err=""
+        if ! init_err=$(cd "$init_dir" && camel init hello.java 2>&1 
>/dev/null); then
+            echo "FAIL: camel init failed after a successful homebrew 
install${init_err:+: $init_err}"
+            rc=1
+        else
+            assert_init_content "$init_dir" "hello.java" || rc=1
+        fi
     fi
 
     # Step 5: Uninstall (tap-qualified name, matching the install above; the 
tap itself is
     # removed afterward by the script's EXIT trap, once nothing installed from 
it remains).
-    local uninstall_rc=0
-    if command -v brew >/dev/null 2>&1; then
-        local brew_uninst_output=""
-        brew_uninst_output=$(HOME="$BREW_HOME" \
-            HOMEBREW_NO_AUTO_UPDATE=1 \
-            brew uninstall "$tap_name/$fmla" --force 2>&1 || true)
-        echo "INFO: homebrew uninstall output:"
-        echo "$brew_uninst_output"
-    fi
-
-    # Verify removal of symlink/bin entry
-    if [ -L "/usr/local/bin/camel" ] || [ -e "/usr/local/bin/camel" ]; then
-        echo "WARN: homebrew uninstall left /usr/local/bin/camel"
+    # Capture where the installed `camel` actually resolves BEFORE 
uninstalling, so the
+    # post-uninstall check targets Homebrew's real prefix (e.g. 
/opt/homebrew/bin on Apple
+    # Silicon, /home/linuxbrew/.linuxbrew/bin on Linux) rather than a 
hardcoded path that is
+    # never populated on either CI runner.
+    local installed_camel=""
+    installed_camel=$(command -v camel 2>/dev/null || true)
+
+    local brew_uninst_output=""
+    brew_uninst_output=$(HOME="$BREW_HOME" \
+        HOMEBREW_NO_AUTO_UPDATE=1 \
+        brew uninstall "$tap_name/$fmla" --force 2>&1) || {
+            echo "FAIL: homebrew uninstall command failed:"
+            echo "$brew_uninst_output"
+            rc=1
+        }
+    echo "INFO: homebrew uninstall output:"
+    echo "$brew_uninst_output"
+
+    # A binary still resolvable at its install location means uninstall did 
not remove it -
+    # a real failure, not a warning.
+    if [ -n "$installed_camel" ] && { [ -e "$installed_camel" ] || [ -L 
"$installed_camel" ]; }; then
+        echo "FAIL: homebrew uninstall left the camel executable at 
$installed_camel"
+        rc=1
     else
-        echo "PASS: homebrew uninstall removed camel executable"
+        echo "PASS: homebrew uninstall removed the camel executable"
     fi
 
     return $rc
@@ -309,15 +406,15 @@ validate_homebrew() {
 # ============================================================
 # Local archive validator (no package manager involved)
 # ============================================================
-# The Homebrew validator's test-mode hack (see camel-package.sh) can install a 
real,
-# already-published camel-launcher release instead of this build's own 
synthetic one, so
-# it no longer guarantees this build's own zip/tar.gz ever actually gets run. 
This
-# validator always runs, independent of any package manager, so that coverage 
isn't lost:
-# it extracts the locally staged archive directly and runs its bin/camel.sh in 
place.
+# Runs this build's own archive directly, with no package manager in the loop. 
Even though the
+# Homebrew validator now installs this build's own binary via a file:// 
rewrite in test mode,
+# this validator is still the one leg guaranteed to run regardless of whether 
Homebrew or SDKMAN
+# is present on the host: it extracts the locally staged archive and runs its 
bin/camel.sh in
+# place.
 
 validate_local_archive() {
     local rc=0
-    local 
archive_file="$MODULE_DIR/target/camel-launcher-${RESOLVED_VERSION}-bin.tar.gz"
+    local 
archive_file="$TARGET_DIR/camel-launcher-${RESOLVED_VERSION}-bin.tar.gz"
 
     if [ ! -f "$archive_file" ]; then
         echo "SKIP: local archive not found: $archive_file (did the build 
run?)"
@@ -344,17 +441,33 @@ validate_local_archive() {
     local camv_output=""
     camv_output=$("$camel_sh" --version 2>/dev/null) || true
     if [ -z "$camv_output" ]; then
-        echo "FAIL: 'camel.sh --version' returned empty output"
+        local camv_err=""
+        camv_err=$("$camel_sh" --version 2>&1 >/dev/null) || true
+        echo "FAIL: 'camel.sh --version' returned empty output${camv_err:+ 
(stderr: $camv_err)}"
         rc=1
     else
-        assert_camel_version "$camv_output" "$RESOLVED_VERSION" && \
-            echo "PASS: local archive version OK" || rc=1
+        # In test mode this archive can be a byte-for-byte copy of a real 
SNAPSHOT build, staged
+        # under a -SNAPSHOT-stripped filename (see the CI workflow's "stage a 
synthetic release
+        # version" step, which `cp`s the archive rather than rebuilding it). 
The binary's embedded
+        # catalog version is whatever the real build produced, so it genuinely 
reports
+        # "$RESOLVED_VERSION-SNAPSHOT" there, not "$RESOLVED_VERSION" - 
confirmed empirically
+        # against a real build. A non-test release never runs in a SNAPSHOT 
state, so only test
+        # mode accepts the suffixed form.
+        local actual_ver
+        actual_ver=$(echo "$camv_output" | head -n 1 | awk '{print $NF}')
+        if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ] && [ "$actual_ver" = 
"${RESOLVED_VERSION}-SNAPSHOT" ]; then
+            echo "PASS: local archive version OK (test-mode staged archive 
reports its real build version '$actual_ver')"
+        else
+            assert_camel_version "$camv_output" "$RESOLVED_VERSION" && \
+                echo "PASS: local archive version OK" || rc=1
+        fi
     fi
 
     local init_dir="$TMPDIR_BASE/local-archive-init"
     mkdir -p "$init_dir"
-    if ! (cd "$init_dir" && "$camel_sh" init hello.java >/dev/null 2>&1); then
-        echo "FAIL: camel init failed against the locally extracted archive"
+    local init_err=""
+    if ! init_err=$(cd "$init_dir" && "$camel_sh" init hello.java 2>&1 
>/dev/null); then
+        echo "FAIL: camel init failed against the locally extracted 
archive${init_err:+: $init_err}"
         rc=1
     else
         assert_init_content "$init_dir" "hello.java" || rc=1
@@ -379,7 +492,7 @@ validate_sdkman() {
     # API-only and writes no local descriptor file under target/jreleaser at 
all, so this always
     # SKIPs offline regardless of the path below; kept for parity with the 
Homebrew validator and
     # in case a future JReleaser version starts writing one.
-    local 
DESCRIPTOR_FILE="$MODULE_DIR/target/jreleaser/package/camel-cli/sdkman/camel.json"
+    local 
DESCRIPTOR_FILE="$TARGET_DIR/jreleaser/package/camel-cli/sdkman/camel.json"
 
     if [ ! -f "$DESCRIPTOR_FILE" ]; then
         echo "SKIP: SDKMAN descriptor not found: $DESCRIPTOR_FILE (did prepare 
run?)"
@@ -396,7 +509,10 @@ validate_sdkman() {
     if command -v jq >/dev/null 2>&1; then
         local desc_version
         desc_version=$(jq -r '.version' "$DESCRIPTOR_FILE" 2>/dev/null) || true
-        if [ -z "$desc_version" ]; then
+        # jq prints the literal string "null" (not empty) when the key is 
absent, so that
+        # must be checked explicitly - otherwise a descriptor missing 
'version' would still
+        # print a false "PASS: ... valid version: null".
+        if [ -z "$desc_version" ] || [ "$desc_version" = "null" ]; then
             echo "FAIL: SDKMAN descriptor missing 'version' field"
             return 1
         fi
@@ -412,7 +528,7 @@ validate_sdkman() {
     fi
 
     # Step 2: Verify offline archive structure (tar.gz)
-    local 
ARCHIVE_FILE="$MODULE_DIR/target/camel-launcher-${RESOLVED_VERSION}-bin.tar.gz"
+    local 
ARCHIVE_FILE="$TARGET_DIR/camel-launcher-${RESOLVED_VERSION}-bin.tar.gz"
     if [ ! -f "$ARCHIVE_FILE" ]; then
         echo "SKIP: SDKMAN archive not found: $ARCHIVE_FILE (did prepare run?)"
         return 0
@@ -432,19 +548,27 @@ validate_sdkman() {
         return 1
     fi
 
-    # Step 3: Verify camel version
-    # For offline validation, we verify the descriptor + archive integrity.
-    # A real sdk install would call the SDKMAN Vendor Release API which is 
stubbed in Phase 5.
-    echo "SKIP: SDKMAN vendor release API stubbed (offline validation)"
-
-    # Step 4: Verify offline camel init route content (same assertion as 
Homebrew)
+    # Step 3: Verify camel version.
+    # For offline validation, we verify the descriptor + archive integrity. A 
real `sdk install`
+    # would call the SDKMAN Vendor Release API, which requires a published 
release and network
+    # access, so it is not exercised in this offline validation.
+    echo "SKIP: SDKMAN vendor release API not exercised (offline validation)"
+
+    # Step 4: Verify offline camel init route content (same assertion as 
Homebrew).
+    # Unlike the Homebrew/local validators, a failing `camel init` here stays 
a WARN rather
+    # than a FAIL: SDKMAN never actually installs anything in this offline 
validation (see
+    # Step 3 above), so any `camel` found on PATH is unrelated host state this 
validator did
+    # not produce - treating its failure as a defect in the SDKMAN packaging 
would be a false
+    # failure. A successful init IS still checked against the content fixture 
below, since
+    # that's a legitimate signal when it happens to be available.
     local init_dir="$TMPDIR_BASE/init-test-sdkman"
     mkdir -p "$init_dir"
     if command -v camel >/dev/null 2>&1; then
-        if cd "$init_dir" && camel init hello.java >/dev/null 2>&1; then
+        local init_err=""
+        if init_err=$(cd "$init_dir" && camel init hello.java 2>&1 
>/dev/null); then
             assert_init_content "$init_dir" "hello.java" || rc=1
         else
-            echo "WARN: camel init skipped (not installed via SDKMAN)"
+            echo "WARN: camel init skipped (not installed via 
SDKMAN)${init_err:+ (stderr: $init_err)}"
         fi
     else
         echo "WARN: camel not available for init test via SDKMAN (skipped)"
@@ -463,9 +587,9 @@ validate_sdkman() {
 main() {
     local rc=0
     case "$COMMAND" in
-        local)    validate_local_archive; rc=$? ;;
-        homebrew) validate_homebrew; rc=$? ;;
-        sdkman)   validate_sdkman; rc=$? ;;
+        local)    validate_local_archive || rc=1 ;;
+        homebrew) validate_homebrew || rc=1 ;;
+        sdkman)   validate_sdkman || rc=1 ;;
         all)
             validate_local_archive || rc=1
             validate_homebrew || rc=1
diff --git 
a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/lib/assert-camel-cli.sh 
b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/lib/assert-camel-cli.sh
index df2335506b4e..624c996e59bb 100755
--- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/lib/assert-camel-cli.sh
+++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/lib/assert-camel-cli.sh
@@ -20,11 +20,12 @@
 # Shared POSIX assertions for camel-validate.sh / assertion library tests
 #
 # Usage (caller must source this file):
-#   assert_camel_version <actual_output> <expected_version>    -- exits 1 on 
mismatch
+#   assert_camel_version <actual_output> <expected_version>    -- returns 1 on 
mismatch
 #   assert_init_content <directory> <filename>                 -- verifies 
content matches fixture
-#   assert_uninstalled <path_or_prefix ...>                    -- each must 
not exist after uninstall
+#   assert_uninstalled <path ...>                               -- each exact 
path must not exist after
+#                                                                   uninstall 
(not called by camel-validate.sh's
+#                                                                   own 
validators, which inline this check)
 #   assert_camel_cli <camel-cmd> <workdir> [expected-version] -- full 
version+init assertion wrapper
-#   assert_camel_absent <path>                                -- verify a 
camel symlink/entry is gone
 # ============================================================================
 
 if [ -n "${BASH_SOURCE+set}" ]; then
@@ -36,14 +37,14 @@ else
 fi
 
 if [ -n "$_raw_source" ]; then
-    _ASSERT_LIB_DIR=$(CDPATH= cd -- "$(dirname -- "$_raw_source")" && pwd)
+    _ASSERT_LIB_DIR=$(CDPATH='' cd -- "$(dirname -- "$_raw_source")" && pwd)
 else
-    _ASSERT_LIB_DIR=$(CDPATH= cd -- "$(dirname -- "${0:-.}")" && pwd)
+    _ASSERT_LIB_DIR=$(CDPATH='' cd -- "$(dirname -- "${0:-.}")" && pwd)
 fi
 unset _raw_source
 
 assertion_pass() { echo "PASS: $1"; }
-assertion_fail()  { echo "FAIL: $1"; assertion_error=1; return 1; }
+assertion_fail()  { echo "FAIL: $1"; return 1; }
 
 assert_camel_version() {
     local actual="$1" expected="$2"
@@ -98,45 +99,45 @@ assert_camel_cli() {
     local _orig_dir
     _orig_dir=$(pwd)
 
-    # Step 1: version check
+    # Step 1: version check. When an expected version is given, a mismatch is 
a real failure
+    # that must propagate out of this wrapper (empty output stays a soft skip 
- the caller may
+    # be validating a CLI that cannot print a version on this host).
     local camv_output
     camv_output=$("$CAMELCMD" --version 2>/dev/null) || true
     if [ -z "$camv_output" ]; then
-        echo "WARN: camel version returned empty output (skipped)"
+        local camv_err=""
+        camv_err=$("$CAMELCMD" --version 2>&1 >/dev/null) || true
+        echo "WARN: camel version returned empty output (skipped)${camv_err:+ 
(stderr: $camv_err)}"
     elif [ -n "$EXPECTED_VERSION" ]; then
-        assert_camel_version "$camv_output" "$EXPECTED_VERSION"
+        assert_camel_version "$camv_output" "$EXPECTED_VERSION" || return 1
     else
         echo "INFO: camel version reported: $camv_output (no expected version 
given, skipping comparison)"
     fi
 
     # Step 2: init content check. Uses "hello.java" (not an arbitrary name) 
because the
-    # fixture's class name is derived from this filename, same as the real 
POSIX validators.
+    # fixture's class name is derived from this filename, same as the real 
POSIX validators. A
+    # failing init is a genuine defect, not something to warn past - matches 
the inline validators
+    # in camel-validate.sh, which FAIL on the same condition.
     cd "$WORKDIR" || { echo "FAIL: cannot cd to $WORKDIR"; return 1; }
     if [ -f hello.java ]; then rm -f hello.java; fi
-    if "$CAMELCMD" init hello.java >/dev/null 2>&1; then
-        assert_init_content "$WORKDIR" "hello.java" || { echo "FAIL: generated 
route missing expected content"; cd "$_orig_dir"; return 1; }
+    local init_err=""
+    if init_err=$("$CAMELCMD" init hello.java 2>&1 >/dev/null); then
+        assert_init_content "$WORKDIR" "hello.java" || { echo "FAIL: generated 
route missing expected content"; cd "$_orig_dir" || exit; return 1; }
     else
-        echo "WARN: camel init failed (skipped)"
+        echo "FAIL: camel init failed${init_err:+: $init_err}"
+        cd "$_orig_dir" || exit
+        return 1
     fi
-    cd "$_orig_dir"
+    cd "$_orig_dir" || exit
 
-    # Step 3: assert the executable exists
+    # Step 3: final sanity check that the resolved command is executable, 
independent of how
+    # the version/init calls above happened to succeed.
     if [ -x "$CAMELCMD" ]; then
         assertion_pass "camel CLI executable found"
     else
-        echo "WARN: camel CLI not executable at '$CAMELCMD' (skipped)"
+        echo "FAIL: camel CLI not executable at '$CAMELCMD'"
+        return 1
     fi
 
     return 0
 }
-
-assert_camel_absent() {
-    local path="$1"
-    if [ -e "$path" ] || [ -L "$path" ]; then
-        assertion_fail "uninstall left behind: $path"
-        return 1
-    else
-        assertion_pass "'$path' does not exist (removed by uninstall)"
-        return 0
-    fi
-}
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackageNativeValidationTest.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackageNativeValidationTest.java
new file mode 100644
index 000000000000..202cb73d5e8f
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackageNativeValidationTest.java
@@ -0,0 +1,525 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.launcher;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Host-gated orchestration tests for the POSIX package-native validator 
dispatcher and assertion library. Runs on macOS
+ * and Linux (disabled on Windows).
+ */
+@DisabledOnOs(OS.WINDOWS)
+class PackageNativeValidationTest {
+
+    private static final Path MODULE_DIR = Path.of("src").toAbsolutePath();
+    private static final Path LIB = 
MODULE_DIR.resolve("jreleaser/bin/lib/assert-camel-cli.sh");
+    private static final Path VALIDATE = 
MODULE_DIR.resolve("jreleaser/bin/camel-validate.sh");
+    private static final Path FIXTURE = 
MODULE_DIR.resolve("test/resources/validate/expected-init-route.txt");
+
+    private static final String VERSION = "4.22.0";
+
+    @Test
+    void assertionLibraryPassesForConformantCli(@TempDir Path tmp) throws 
Exception {
+        Path fake = writeFakeCamel(tmp.resolve("camel"), VERSION);
+        String script = ". " + LIB.toAbsolutePath()
+                        + "; assert_camel_cli '" + fake + "' '" + tmp + "' '" 
+ VERSION + "'"
+                        + " && assert_uninstalled '" + 
tmp.resolve("does-not-exist-camel").toAbsolutePath() + "'";
+        Result r = sh(null, List.of("ASSERT_INIT_FIXTURE=" + 
FIXTURE.toAbsolutePath()), script);
+
+        assertThat(r.exit()).as(r.out()).isZero();
+        // Assert the specific behavioral lines, not just "PASS": a passing 
version comparison and a
+        // fixture-matching init route are what this test exists to protect.
+        assertThat(r.out())
+                .contains("camel version matches expected '" + VERSION + "'")
+                .contains("camel init route content matches expected fixture")
+                .doesNotContain("FAIL");
+    }
+
+    @Test
+    void assertionLibraryFailsWhenInitContentMissing(@TempDir Path tmp) throws 
Exception {
+        // Version reports correctly (so the version check passes); only the 
init route content is wrong,
+        // so the failure this test asserts can only come from the 
init-content comparison.
+        Path fake = writeExecutable(tmp.resolve("camel"),
+                "#!/bin/sh\n"
+                                                          + "case \"$1\" in\n"
+                                                          + "  --version) echo 
'Apache Camel " + VERSION + "' ;;\n"
+                                                          + "  init) echo 
'nope' > \"$2\" ;;\n"
+                                                          + "  *) exit 3 ;;\n"
+                                                          + "esac\n");
+        String script = ". " + LIB.toAbsolutePath() + "; assert_camel_cli '" + 
fake + "' '" + tmp + "' '" + VERSION + "'";
+        Result r = sh(null, List.of("ASSERT_INIT_FIXTURE=" + 
FIXTURE.toAbsolutePath()), script);
+
+        assertThat(r.exit()).as(r.out()).isNotZero();
+        assertThat(r.out())
+                .contains("camel init route content differs from fixture")
+                .doesNotContain("camel init route content matches expected 
fixture");
+    }
+
+    @Test
+    void assertionLibraryFailsOnVersionMismatch(@TempDir Path tmp) throws 
Exception {
+        // Init content is correct, but the reported version is wrong. This 
pins the fix that makes a
+        // version mismatch propagate out of assert_camel_cli instead of being 
printed and swallowed.
+        Path fake = writeFakeCamel(tmp.resolve("camel"), "9.9.9");
+        String script = ". " + LIB.toAbsolutePath() + "; assert_camel_cli '" + 
fake + "' '" + tmp + "' '" + VERSION + "'";
+        Result r = sh(null, List.of("ASSERT_INIT_FIXTURE=" + 
FIXTURE.toAbsolutePath()), script);
+
+        assertThat(r.exit()).as(r.out()).isNotZero();
+        assertThat(r.out()).contains("camel version mismatch: expected '" + 
VERSION + "', got '9.9.9'");
+    }
+
+    @Test
+    void unknownTargetIsUsageError() throws Exception {
+        // The script's contract is to reject an unknown command with exit 
code 2; assert the exit code
+        // directly rather than only grepping output (usage text prints for 
valid commands too).
+        Result r = sh(null, List.of("PATH=/usr/bin:/bin"), "/bin/sh " + 
VALIDATE.toAbsolutePath() + " frobnicate");
+
+        assertThat(r.exit()).as(r.out()).isEqualTo(2);
+        assertThat(r.out()).contains("Error:").contains("unknown command");
+    }
+
+    @Test
+    void allValidatorsSelfSkipWhenNothingIsStaged(@TempDir Path emptyTarget) 
throws Exception {
+        // With an empty target dir, every validator has nothing to act on: 
local finds no archive,
+        // homebrew finds no formula, SDKMAN finds no descriptor. All must 
SKIP, none may FAIL, exit 0.
+        Result r = sh(null, List.of(
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + emptyTarget.toAbsolutePath()),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " all");
+
+        assertThat(r.exit()).as(r.out()).isZero();
+        assertThat(r.out()).contains("SKIP:").doesNotContain("FAIL");
+    }
+
+    @Test
+    void localArchiveValidatorPassesForStagedArchive(@TempDir Path target) 
throws Exception {
+        // Stage a real tar.gz laid out exactly like the release archive 
(camel-launcher-<v>/bin/camel.sh)
+        // and run the `local` validator against it. This is the one validator 
the whole dispatcher design
+        // is built around, so it gets a genuine end-to-end happy-path 
assertion, not just an exit code.
+        stageLocalArchive(target);
+        Result r = sh(null, List.of(
+                "ASSERT_INIT_FIXTURE=" + FIXTURE.toAbsolutePath(),
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + target.toAbsolutePath()),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " local");
+
+        assertThat(r.exit()).as(r.out()).isZero();
+        assertThat(r.out())
+                .contains("PASS: local archive version OK")
+                .contains("camel init route content matches expected fixture")
+                .doesNotContain("FAIL");
+    }
+
+    @Test
+    void localArchiveValidatorAcceptsSnapshotVersionInTestMode(@TempDir Path 
target) throws Exception {
+        // In test mode the staged archive can be a byte-for-byte copy of a 
real SNAPSHOT build (see the CI
+        // workflow's "stage a synthetic release version" step, which cp's the 
archive rather than rebuilding
+        // it), so its embedded catalog version genuinely reports 
"<VERSION>-SNAPSHOT", not "<VERSION>". Pins
+        // the tolerant comparison branch that accepts this instead of failing 
it as a version mismatch.
+        stageLocalArchive(target, VERSION + "-SNAPSHOT");
+        Result r = sh(null, List.of(
+                "ASSERT_INIT_FIXTURE=" + FIXTURE.toAbsolutePath(),
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + target.toAbsolutePath()),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " local");
+
+        assertThat(r.exit()).as(r.out()).isZero();
+        assertThat(r.out())
+                .contains("PASS: local archive version OK (test-mode staged 
archive reports its real build "
+                          + "version '" + VERSION + "-SNAPSHOT')")
+                .doesNotContain("FAIL");
+    }
+
+    @Test
+    void localArchiveValidatorSurfacesStderrOnEmptyVersionOutput(@TempDir Path 
target) throws Exception {
+        // The hardening pass added stderr capture to every 
FAIL-on-empty-version-output branch so the failure
+        // message carries a reason instead of just "returned empty output". 
Pin that the "(stderr: ...)"
+        // suffix actually appears for the local-archive validator's own FAIL 
branch.
+        stageLocalArchiveWithCamelShBody(target, "#!/bin/sh\n"
+                                                 + "case \"$1\" in\n"
+                                                 + "  --version) echo 'boom: 
cannot determine version' >&2; exit 1 ;;\n"
+                                                 + "  *) exit 3 ;;\n"
+                                                 + "esac\n");
+
+        Result r = sh(null, List.of(
+                "ASSERT_INIT_FIXTURE=" + FIXTURE.toAbsolutePath(),
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + target.toAbsolutePath()),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " local");
+
+        assertThat(r.exit()).as(r.out()).isNotZero();
+        assertThat(r.out())
+                .contains("FAIL: 'camel.sh --version' returned empty output")
+                .contains("(stderr: boom: cannot determine version)");
+    }
+
+    @Test
+    void versionOverrideRequiresTestMode() throws Exception {
+        // CAMEL_PACKAGE_TEST_VERSION without CAMEL_PACKAGE_TEST_MODE=true 
must be rejected (exit 2), so
+        // production can never silently run against a synthetic version.
+        Result r = sh(null, List.of("CAMEL_PACKAGE_TEST_VERSION=" + VERSION),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " local");
+
+        assertThat(r.exit()).as(r.out()).isEqualTo(2);
+        assertThat(r.out()).contains("CAMEL_PACKAGE_TEST_VERSION requires 
CAMEL_PACKAGE_TEST_MODE=true");
+    }
+
+    @Test
+    void unknownOptionIsUsageError() throws Exception {
+        Result r = sh(null, List.of("PATH=/usr/bin:/bin"), "/bin/sh " + 
VALIDATE.toAbsolutePath() + " all --bogus");
+
+        assertThat(r.exit()).as(r.out()).isEqualTo(2);
+        assertThat(r.out()).contains("unknown option");
+    }
+
+    @Test
+    void helpIsUsageError() throws Exception {
+        Result r = sh(null, List.of("PATH=/usr/bin:/bin"), "/bin/sh " + 
VALIDATE.toAbsolutePath() + " help");
+
+        assertThat(r.exit()).as(r.out()).isEqualTo(2);
+        assertThat(r.out()).contains("Usage:");
+    }
+
+    @Test
+    void invalidChannelIsUsageError() throws Exception {
+        Result r = sh(null, List.of("PATH=/usr/bin:/bin"),
+                "/bin/sh " + VALIDATE.toAbsolutePath() + " all --channel 
bogus");
+
+        assertThat(r.exit()).as(r.out()).isEqualTo(2);
+        assertThat(r.out()).contains("Error:").contains("--channel must be 
'stable' or 'lts'");
+    }
+
+    @Test
+    void ltsChannelWithoutLtsLineIsUsageError() throws Exception {
+        Result r = sh(null, List.of("PATH=/usr/bin:/bin"),
+                "/bin/sh " + VALIDATE.toAbsolutePath() + " all --channel lts");
+
+        assertThat(r.exit()).as(r.out()).isEqualTo(2);
+        assertThat(r.out()).contains("Error:").contains("--channel lts 
requires --lts-line X.Y");
+    }
+
+    @Test
+    void ltsChannelWithLtsLineIsAccepted(@TempDir Path emptyTarget) throws 
Exception {
+        // With --lts-line supplied, --channel lts must pass argument 
validation and proceed to the
+        // validators themselves (which SKIP here since nothing is staged), 
rather than hitting the usage
+        // error pinned by this test's two siblings above.
+        Result r = sh(null, List.of(
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + emptyTarget.toAbsolutePath()),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " all --channel lts 
--lts-line 4.21");
+
+        assertThat(r.exit()).as(r.out()).isZero();
+        
assertThat(r.out()).contains("SKIP:").doesNotContain("FAIL").doesNotContain("Error:");
+    }
+
+    @Test
+    void dispatcherPropagatesValidatorFailure(@TempDir Path target) throws 
Exception {
+        // A validator that FAILs (here the local archive reports the wrong 
version) must make the
+        // dispatcher itself exit nonzero - the FAIL must not be swallowed by 
main's rc aggregation.
+        stageLocalArchive(target, "9.9.9");
+        Result r = sh(null, List.of(
+                "ASSERT_INIT_FIXTURE=" + FIXTURE.toAbsolutePath(),
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + target.toAbsolutePath()),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " local");
+
+        assertThat(r.exit()).as(r.out()).isNotZero();
+        assertThat(r.out()).contains("camel version 
mismatch").contains("FAIL");
+    }
+
+    @Test
+    void allDispatcherAggregatesAFailureAcrossValidators(@TempDir Path target) 
throws Exception {
+        // Unlike dispatcherPropagatesValidatorFailure above (which calls the 
`local` validator
+        // directly), this drives main()'s actual `all` dispatch: 
validate_local_archive || rc=1;
+        // validate_homebrew || rc=1; validate_sdkman || rc=1. Nothing is 
staged for homebrew/SDKMAN,
+        // so they SKIP - proving the local FAIL doesn't short-circuit the 
remaining legs and still
+        // survives to the aggregated exit code.
+        stageLocalArchive(target, "9.9.9");
+        Result r = sh(null, List.of(
+                "ASSERT_INIT_FIXTURE=" + FIXTURE.toAbsolutePath(),
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + target.toAbsolutePath()),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " all");
+
+        assertThat(r.exit()).as(r.out()).isNotZero();
+        assertThat(r.out())
+                .contains("camel version mismatch")
+                .contains("FAIL")
+                // Which SKIP reason fires depends on whether `brew` is on 
PATH for the host running this
+                // test: package-native-validation.yml's 
ubuntu-latest/macos-latest legs put Linuxbrew/
+                // Homebrew on PATH first, so validate_homebrew() gets past 
the host-gate and SKIPs on the
+                // formula file (never staged here). The generic "Build and 
test" workflow runs this same
+                // test via a plain reactor `mvn test` with no such PATH 
setup, so it SKIPs at the host-gate
+                // instead. Either way the local FAIL didn't short-circuit the 
homebrew leg, which is what
+                // this test is actually proving.
+                .containsPattern("SKIP: homebrew (not available|formula not 
found)")
+                // `sdk` is a shell function sourced from sdkman-init.sh, not 
a PATH binary, so a bare
+                // subprocess (this test's `sh`, and CI's separate "Run POSIX 
validator unit tests" step)
+                // always hits the host-gate SKIP rather than the 
descriptor-not-found one.
+                .contains("SKIP: sdkman not available");
+    }
+
+    @Test
+    void assertUninstalledFlagsLeftoverButPassesWhenAbsent(@TempDir Path tmp) 
throws Exception {
+        // Exercise the multi-arg assert_uninstalled helper directly: 
all-absent passes, a leftover fails.
+        Path present = Files.writeString(tmp.resolve("leftover"), "x");
+        Path absent = tmp.resolve("gone");
+
+        Result ok = sh(null, List.of(), ". " + LIB.toAbsolutePath() + "; 
assert_uninstalled '" + absent + "'");
+        assertThat(ok.exit()).as(ok.out()).isZero();
+        assertThat(ok.out()).contains("does not exist");
+
+        Result bad = sh(null, List.of(), ". " + LIB.toAbsolutePath() + "; 
assert_uninstalled '" + present + "'");
+        assertThat(bad.exit()).as(bad.out()).isNotZero();
+        assertThat(bad.out()).contains("uninstall left behind");
+    }
+
+    @Test
+    void homebrewValidatorFailsWhenAuditStrictReportsIssues(@TempDir Path tmp) 
throws Exception {
+        // Pin that a nonzero `brew audit --strict` exit always propagates as 
a FAIL and a nonzero
+        // overall exit, never a warning.
+        Path fakeBinDir = Files.createDirectory(tmp.resolve("fake-bin"));
+        writeFakeBrew(fakeBinDir.resolve("brew"));
+        Path target = Files.createDirectory(tmp.resolve("target"));
+        Path tapDir = Files.createDirectory(tmp.resolve("tap"));
+        stageHomebrewFormula(target, "camel-launcher-9.9.9-bin.tar.gz");
+
+        Result r = sh(null, List.of(
+                "PATH=" + fakeBinDir.toAbsolutePath() + File.pathSeparator + 
System.getenv("PATH"),
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + target.toAbsolutePath(),
+                "FAKE_BREW_TAP_DIR=" + tapDir.toAbsolutePath(),
+                "FAKE_BREW_AUDIT_RC=1",
+                "FAKE_BREW_AUDIT_OUTPUT=Error: FormulaAudit/Foo: fake audit 
issue"),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " homebrew");
+
+        assertThat(r.exit()).as(r.out()).isNotZero();
+        assertThat(r.out()).contains("FAIL: homebrew audit --strict reported 
issues (exit 1)");
+    }
+
+    @Test
+    void homebrewValidatorSkipsWhenJavaDependencyMissing(@TempDir Path tmp) 
throws Exception {
+        // A `brew install` failure caused by a missing/incompatible Java on 
the host is an environment
+        // limitation, not a defect in the generated formula, and must SKIP 
(exit 0), not FAIL - this pins
+        // the classification heuristic so a real regression can't get 
silently downgraded the same way.
+        Path fakeBinDir = Files.createDirectory(tmp.resolve("fake-bin"));
+        writeFakeBrew(fakeBinDir.resolve("brew"));
+        Path target = Files.createDirectory(tmp.resolve("target"));
+        Path tapDir = Files.createDirectory(tmp.resolve("tap"));
+        stageHomebrewFormula(target, "camel-launcher-9.9.9-bin.tar.gz");
+
+        Result r = sh(null, List.of(
+                "PATH=" + fakeBinDir.toAbsolutePath() + File.pathSeparator + 
System.getenv("PATH"),
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + target.toAbsolutePath(),
+                "FAKE_BREW_TAP_DIR=" + tapDir.toAbsolutePath(),
+                "FAKE_BREW_INSTALL_RC=1",
+                "FAKE_BREW_INSTALL_OUTPUT=Error: no suitable java version 
found, please install java"),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " homebrew");
+
+        assertThat(r.exit()).as(r.out()).isZero();
+        assertThat(r.out())
+                .contains("SKIP: homebrew install could not resolve a Java 
dependency")
+                .doesNotContain("FAIL");
+    }
+
+    @Test
+    void homebrewAuditFailureSurvivesASubsequentInstallSkip(@TempDir Path tmp) 
throws Exception {
+        // A failed `brew audit --strict` is a real formula defect (see
+        // homebrewValidatorFailsWhenAuditStrictReportsIssues above). If 
install *also* hits an
+        // unrelated environment limitation (missing Java) afterwards, that 
SKIP must not erase the
+        // already-recorded audit failure - the two conditions are independent 
and both can be true at
+        // once. Pins the fix for validate_homebrew's SKIP branches returning 
the accumulated $rc
+        // instead of an unconditional 0.
+        Path fakeBinDir = Files.createDirectory(tmp.resolve("fake-bin"));
+        writeFakeBrew(fakeBinDir.resolve("brew"));
+        Path target = Files.createDirectory(tmp.resolve("target"));
+        Path tapDir = Files.createDirectory(tmp.resolve("tap"));
+        stageHomebrewFormula(target, "camel-launcher-9.9.9-bin.tar.gz");
+
+        Result r = sh(null, List.of(
+                "PATH=" + fakeBinDir.toAbsolutePath() + File.pathSeparator + 
System.getenv("PATH"),
+                "CAMEL_PACKAGE_TEST_MODE=true",
+                "CAMEL_PACKAGE_TEST_VERSION=" + VERSION,
+                "CAMEL_VALIDATE_TARGET_DIR=" + target.toAbsolutePath(),
+                "FAKE_BREW_TAP_DIR=" + tapDir.toAbsolutePath(),
+                "FAKE_BREW_AUDIT_RC=1",
+                "FAKE_BREW_AUDIT_OUTPUT=Error: FormulaAudit/Foo: fake audit 
issue",
+                "FAKE_BREW_INSTALL_RC=1",
+                "FAKE_BREW_INSTALL_OUTPUT=Error: no suitable java version 
found, please install java"),
+                "/bin/bash " + VALIDATE.toAbsolutePath() + " homebrew");
+
+        assertThat(r.exit()).as(r.out()).isNotZero();
+        assertThat(r.out())
+                .contains("FAIL: homebrew audit --strict reported issues (exit 
1)")
+                .contains("SKIP: homebrew install could not resolve a Java 
dependency");
+    }
+
+    // Note: the sibling "Failed to download resource|curl: (" SKIP branch 
(camel-validate.sh, same
+    // return "$rc" fix as above) is not covered by an equivalent test here. 
It only triggers when
+    // CAMEL_PACKAGE_TEST_MODE is NOT "true", but CAMEL_VALIDATE_TARGET_DIR 
(needed to point the
+    // validator at an isolated @TempDir instead of this module's real 
target/) requires
+    // CAMEL_PACKAGE_TEST_MODE=true - the two guards are mutually exclusive, 
so this branch cannot be
+    // reached hermetically the way every other test in this file is. It 
shares the exact same
+    // `return "$rc"` line as the covered branch above, so the risk left 
uncovered is narrow: whether
+    // the "Failed to download resource" text is classified correctly, not the 
rc-propagation logic.
+
+    // ---------- helpers ----------
+
+    /** Body of a conformant fake `camel` CLI: reports {@code version} and 
generates the fixture route on init. */
+    private static String fakeCamelBody(String version) {
+        return "#!/bin/sh\n"
+               + "case \"$1\" in\n"
+               + "  --version) echo 'Apache Camel " + version + "'; exit 0 
;;\n"
+               + "  init) name=$(basename \"$2\" .java); "
+               + "sed \"s/public class hello/public class $name/\" '" + 
FIXTURE.toAbsolutePath()
+               + "' > \"$2\"; exit 0 ;;\n"
+               + "  *) exit 3 ;;\n"
+               + "esac\n";
+    }
+
+    private static Path writeFakeCamel(Path file, String version) throws 
IOException {
+        return writeExecutable(file, fakeCamelBody(version));
+    }
+
+    /**
+     * A fake {@code brew} that stands in for Homebrew in {@code 
validate_homebrew} so its SKIP-vs-FAIL classification
+     * heuristics can be pinned without needing a real Homebrew install. 
Behavior for the {@code audit} and
+     * {@code install} subcommands is driven by {@code 
FAKE_BREW_AUDIT_RC}/{@code
+     * FAKE_BREW_AUDIT_OUTPUT} and {@code FAKE_BREW_INSTALL_RC}/{@code 
FAKE_BREW_INSTALL_OUTPUT}; the tap directory it
+     * reports is {@code FAKE_BREW_TAP_DIR}. Every other subcommand it needs 
(tap-new, untap, style, uninstall) just
+     * succeeds.
+     */
+    private static Path writeFakeBrew(Path file) throws IOException {
+        return writeExecutable(file, "#!/bin/sh\n"
+                                     + "case \"$1\" in\n"
+                                     + "    tap-new) mkdir -p 
\"$FAKE_BREW_TAP_DIR/Formula\"; exit 0 ;;\n"
+                                     + "    --repository) echo 
\"$FAKE_BREW_TAP_DIR\"; exit 0 ;;\n"
+                                     + "    untap) exit 0 ;;\n"
+                                     + "    style) exit 0 ;;\n"
+                                     + "    audit)\n"
+                                     + "        [ -n 
\"$FAKE_BREW_AUDIT_OUTPUT\" ] && echo \"$FAKE_BREW_AUDIT_OUTPUT\"\n"
+                                     + "        exit 
\"${FAKE_BREW_AUDIT_RC:-0}\" ;;\n"
+                                     + "    install)\n"
+                                     + "        [ -n 
\"$FAKE_BREW_INSTALL_OUTPUT\" ] && echo \"$FAKE_BREW_INSTALL_OUTPUT\"\n"
+                                     + "        exit 
\"${FAKE_BREW_INSTALL_RC:-0}\" ;;\n"
+                                     + "    uninstall) exit 0 ;;\n"
+                                     + "    *) exit 0 ;;\n"
+                                     + "esac\n");
+    }
+
+    /**
+     * Stages {@code 
<targetDir>/jreleaser/package/camel-cli/brew/Formula/apache-camel.rb} (the path
+     * {@code validate_homebrew} reads for the default "stable" channel) plus 
a same-named dummy archive under
+     * {@code targetDir}, so the script's real {@code sed}-based url/sha256 
rewrite for the offline install has
+     * something to operate on.
+     */
+    private static void stageHomebrewFormula(Path targetDir, String 
archiveBasename) throws Exception {
+        Path formulaDir = 
Files.createDirectories(targetDir.resolve("jreleaser/package/camel-cli/brew/Formula"));
+        Files.writeString(targetDir.resolve(archiveBasename), "dummy archive 
content");
+        String formula = "class ApacheCamel < Formula\n"
+                         + "  url 
\"https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/9.9.9/";
+                         + archiveBasename + "\"\n"
+                         + "  sha256 \"" + "0".repeat(64) + "\"\n"
+                         + "  version \"9.9.9\"\n"
+                         + "end\n";
+        Files.writeString(formulaDir.resolve("apache-camel.rb"), formula);
+    }
+
+    private static Path writeExecutable(Path file, String body) throws 
IOException {
+        Files.writeString(file, body, StandardCharsets.UTF_8);
+        Files.setPosixFilePermissions(file, Set.of(
+                PosixFilePermission.OWNER_READ,
+                PosixFilePermission.OWNER_WRITE,
+                PosixFilePermission.OWNER_EXECUTE));
+        return file;
+    }
+
+    private static void stageLocalArchive(Path targetDir) throws Exception {
+        stageLocalArchive(targetDir, PackageNativeValidationTest.VERSION);
+    }
+
+    /**
+     * Builds {@code <targetDir>/camel-launcher-<VERSION>-bin.tar.gz} whose 
bin/camel.sh reports {@code reportedVersion}
+     * (pass a value other than VERSION to drive a version-mismatch FAIL).
+     */
+    private static void stageLocalArchive(Path targetDir, String 
reportedVersion) throws Exception {
+        stageLocalArchiveWithCamelShBody(targetDir, 
fakeCamelBody(reportedVersion));
+    }
+
+    /** Same staging as {@link #stageLocalArchive(Path, String)}, but with an 
arbitrary bin/camel.sh body. */
+    private static void stageLocalArchiveWithCamelShBody(Path targetDir, 
String camelShBody) throws Exception {
+        Path work = Files.createTempDirectory("camel-archive");
+        String root = "camel-launcher-" + PackageNativeValidationTest.VERSION;
+        Path bin = work.resolve(root).resolve("bin");
+        Files.createDirectories(bin);
+        writeExecutable(bin.resolve("camel.sh"), camelShBody);
+
+        Files.createDirectories(targetDir);
+        Path archive = targetDir.resolve(root + "-bin.tar.gz");
+        Result r = sh(work, List.of(), "tar czf '" + archive + "' '" + root + 
"'");
+        assertThat(r.exit()).as("staging archive failed: " + r.out()).isZero();
+    }
+
+    private static Result sh(Path cwd, List<String> envOverrides, String... 
commands) throws Exception {
+        ProcessBuilder pb = new ProcessBuilder("/bin/sh", "-c", String.join(" 
&& ", commands));
+        if (cwd != null) {
+            pb.directory(cwd.toFile());
+        }
+        for (String e : envOverrides) {
+            int eq = e.indexOf('=');
+            pb.environment().put(e.substring(0, eq), e.substring(eq + 1));
+        }
+
+        pb.redirectErrorStream(true);
+        Process p = pb.start();
+        String out = new String(p.getInputStream().readAllBytes(), 
StandardCharsets.UTF_8);
+        boolean completed = p.waitFor(120, TimeUnit.SECONDS);
+        if (!completed) {
+            p.destroyForcibly();
+        }
+        int exitCode = completed ? p.exitValue() : -1;
+
+        return new Result(exitCode, out);
+    }
+
+    record Result(int exit, String out) {
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java
index 8f62ba6bdb8e..0fbda6496e9d 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java
@@ -699,6 +699,17 @@ class WebsiteInstallTest {
                     .resolve(version);
         }
 
+        // Runs the installed 'camel' shim by name, resolved through 
PATH+PATHEXT with binDir prepended,
+        // the way an end user invokes it after install. See 
forwardsArgumentsAndExitCodeThroughShim for
+        // why a bare command name is used rather than the shim's absolute 
path.
+        private static ProcessBuilder shimProcess(Path binDir, String... args) 
{
+            List<String> command = new ArrayList<>(List.of("cmd.exe", "/c", 
"camel"));
+            command.addAll(Arrays.asList(args));
+            ProcessBuilder pb = new 
ProcessBuilder(command).redirectErrorStream(true);
+            pb.environment().put("PATH", binDir + File.pathSeparator + 
System.getenv("PATH"));
+            return pb;
+        }
+
         private static void assertVersionInstalled(Path home, String version) 
throws Exception {
             Path shim = expectedBinDir(home).resolve("camel.cmd");
             assertTrue(Files.isRegularFile(shim), "expected shim at " + shim);
@@ -1020,16 +1031,20 @@ class WebsiteInstallTest {
                 publishLatest(fixture, "1.0.0");
                 assertEquals(0, install(fixture, home, null).exit());
 
-                Path shim = expectedBinDir(home).resolve("camel.cmd");
+                Path binDir = expectedBinDir(home);
+                assertTrue(Files.isRegularFile(binDir.resolve("camel.cmd")), 
"expected shim in " + binDir);
 
-                Process argsProc = new ProcessBuilder("cmd.exe", "/c", 
shim.toString(), "echo-args", "foo", "bar baz")
-                        .redirectErrorStream(true).start();
+                // Invoke the shim by name via PATH, exactly as an end user 
would after install adds bin to
+                // PATH. Passing the shim's absolute path to `cmd /c` together 
with a second quoted argument
+                // ("bar baz") trips cmd.exe's quote-stripping rule (it only 
preserves quotes when there are
+                // exactly two), which would unquote the space in "Apache 
Camel" and break the call. A bare
+                // command name resolved through PATH+PATHEXT avoids that 
quirk and mirrors real usage.
+                Process argsProc = shimProcess(binDir, "echo-args", "foo", 
"bar baz").start();
                 String argsOut = new 
String(argsProc.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
                 argsProc.waitFor(30, TimeUnit.SECONDS);
                 assertTrue(argsOut.contains("foo") && argsOut.contains("bar 
baz"), argsOut);
 
-                Process exitProc = new ProcessBuilder("cmd.exe", "/c", 
shim.toString(), "exit-code", "7")
-                        .redirectErrorStream(true).start();
+                Process exitProc = shimProcess(binDir, "exit-code", 
"7").start();
                 exitProc.waitFor(30, TimeUnit.SECONDS);
                 assertEquals(7, exitProc.exitValue());
             }
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java
index dce2789cbda2..a94857d9b708 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java
@@ -96,29 +96,59 @@ final class WebsiteInstallerFixture implements 
AutoCloseable {
 
     static WebsiteInstallerFixture start(Path temp) throws Exception {
         Files.createDirectories(temp);
-        Path keystore = temp.resolve("installer-test.p12");
-        Path caCertPem = temp.resolve("installer-test-ca.pem");
-        generateSelfSignedKeystore(keystore, caCertPem);
-
-        KeyStore keyStore = KeyStore.getInstance("PKCS12");
-        try (var in = Files.newInputStream(keystore)) {
-            keyStore.load(in, KEYSTORE_PASSWORD.toCharArray());
-        }
-        KeyManagerFactory kmf = 
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
-        kmf.init(keyStore, KEYSTORE_PASSWORD.toCharArray());
-        SSLContext sslContext = SSLContext.getInstance("TLS");
-        sslContext.init(kmf.getKeyManagers(), null, new SecureRandom());
+        SharedTls tls = sharedTls();
 
         HttpsServer server = HttpsServer.create(new 
InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
-        server.setHttpsConfigurator(new HttpsConfigurator(sslContext));
+        server.setHttpsConfigurator(new HttpsConfigurator(tls.sslContext()));
 
-        WebsiteInstallerFixture fixture = new WebsiteInstallerFixture(server, 
temp, caCertPem);
+        WebsiteInstallerFixture fixture = new WebsiteInstallerFixture(server, 
temp, tls.caCertPem());
         server.createContext("/", fixture::handle);
         server.setExecutor(Executors.newCachedThreadPool());
         server.start();
         return fixture;
     }
 
+    /** Loopback TLS material (server key pair + exported CA) shared by every 
fixture instance in the JVM. */
+    private record SharedTls(SSLContext sslContext, Path caCertPem) {
+    }
+
+    private static volatile SharedTls sharedTls;
+
+    // Minting the self-signed cert costs two keytool.exe launches (each a 
full JVM cold start on Windows),
+    // which dominated per-test wall time when done in every start(). The cert 
is not test-specific
+    // (CN=127.0.0.1), so generate it once and reuse it - every fixture still 
binds its own ephemeral port
+    // and serves its own content. Double-checked locking keeps the one-time 
generation thread-safe.
+    private static SharedTls sharedTls() throws Exception {
+        SharedTls local = sharedTls;
+        if (local != null) {
+            return local;
+        }
+        synchronized (WebsiteInstallerFixture.class) {
+            if (sharedTls != null) {
+                return sharedTls;
+            }
+            Path dir = Files.createTempDirectory("camel-installer-tls");
+            dir.toFile().deleteOnExit();
+            Path keystore = dir.resolve("installer-test.p12");
+            Path caCertPem = dir.resolve("installer-test-ca.pem");
+            keystore.toFile().deleteOnExit();
+            caCertPem.toFile().deleteOnExit();
+            generateSelfSignedKeystore(keystore, caCertPem);
+
+            KeyStore keyStore = KeyStore.getInstance("PKCS12");
+            try (var in = Files.newInputStream(keystore)) {
+                keyStore.load(in, KEYSTORE_PASSWORD.toCharArray());
+            }
+            KeyManagerFactory kmf = 
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+            kmf.init(keyStore, KEYSTORE_PASSWORD.toCharArray());
+            SSLContext sslContext = SSLContext.getInstance("TLS");
+            sslContext.init(kmf.getKeyManagers(), null, new SecureRandom());
+
+            sharedTls = new SharedTls(sslContext, caCertPem);
+            return sharedTls;
+        }
+    }
+
     private static void generateSelfSignedKeystore(Path keystore, Path 
caCertPem) throws Exception {
         runTool(new ProcessBuilder(
                 keytool(),
@@ -126,13 +156,21 @@ final class WebsiteInstallerFixture implements 
AutoCloseable {
                 "-alias", KEY_ALIAS,
                 "-keyalg", "RSA",
                 "-keysize", "2048",
-                "-validity", "2",
+                // Long validity because a single shared cert now backs every 
fixture for the whole JVM
+                // lifetime, which under a reused mvnd daemon can span days. 
Loopback-only, never trusted
+                // system-wide, so a wide window is harmless.
+                "-validity", "3650",
                 "-keystore", keystore.toString(),
                 "-storetype", "PKCS12",
                 "-storepass", KEYSTORE_PASSWORD,
                 "-keypass", KEYSTORE_PASSWORD,
                 "-dname", "CN=127.0.0.1",
-                "-ext", "san=ip:127.0.0.1"));
+                "-ext", "san=ip:127.0.0.1",
+                // install.ps1's Windows PowerShell 5.1 path builds a .NET 
X509Chain against this
+                // exported cert (see install.ps1's 
ServerCertificateValidationCallback); X509Chain.Build
+                // only accepts a chain anchor whose Basic Constraints mark it 
as a CA, which keytool does
+                // not set by default.
+                "-ext", "bc:c"));
 
         runTool(new ProcessBuilder(
                 keytool(),


Reply via email to