diff --git a/.github/workflows/camel-launcher-native-exe.yml b/.github/workflows/camel-launcher-native-exe.yml index 952aadf8c7009..f9b56e57dc508 100644 --- a/.github/workflows/camel-launcher-native-exe.yml +++ b/.github/workflows/camel-launcher-native-exe.yml @@ -107,7 +107,7 @@ jobs: # Plain mvn: this reactor is only a handful of modules, so the mvnd daemon # brings no parallelism benefit and adds a cold-start failure mode. run: | - mvn -pl buildingtools,tooling/camel-exe -am verify -Dcamel.exe.build=true -DskipTests + mvn -B -pl buildingtools,tooling/camel-exe -am verify -Dcamel.exe.build=true -DskipTests - name: Upload Windows x64 camel.exe uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -139,7 +139,8 @@ jobs: - name: Smoke test Windows x64 camel.exe shell: bash run: | - mvn -B -ntp -pl buildingtools,tooling/camel-exe -am test -Dtest=CamelExeBootstrapTest -Dsurefire.failIfNoSpecifiedTests=false + mvn -B -ntp -pl buildingtools,tooling/camel-exe -am test \ + -Dtest=CamelExeBootstrapTest -Dsurefire.failIfNoSpecifiedTests=false camel-launcher-native: needs: detect-changes @@ -174,6 +175,14 @@ jobs: fi echo "Launcher upstream snapshots for ${VERSION} are available." - uses: ./.github/actions/setup-llvm-mingw + - name: Install the launcher's in-repo build plugins + # 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 + # and any change made to it in this PR is silently not exercised. Maven cannot consume a + # plugin produced by the same reactor invocation, hence the separate installation. + run: | + mvn -B -P apache-snapshots -ntp \ + -pl tooling/maven/camel-repackager-maven-plugin install -DskipTests - name: Build launcher with native camel.exe (x64 + arm64) # No -am: build only the listed modules. The launcher's other in-repo # dependencies (camel-jbang-core, its jbang plugins, camel-core, ...) resolve @@ -186,12 +195,12 @@ jobs: # daemon brings no parallelism benefit and adds a cold-start failure mode. # -P apache-snapshots is required here so the upstream modules resolve. run: | - mvn -P apache-snapshots \ + mvn -B -P apache-snapshots \ -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher verify \ -Dcamel.exe.build=true -DskipTests - name: Assert archive carries both exe files run: | - ZIP=$(find dsl/camel-jbang/camel-launcher/target -maxdepth 1 -name 'camel-launcher-*-bin.zip' -print -quit) + ZIP=$(find dsl/camel-jbang/camel-launcher/target -maxdepth 1 -name 'camel-launcher-*-winget-bin.zip' -print -quit) echo "Checking archive: $ZIP" ENTRIES=$(unzip -l "$ZIP" | grep -Ec '/bin/camel-(x64|arm64)\.exe$') if [ "$ENTRIES" -ne 2 ]; then @@ -200,3 +209,62 @@ jobs: exit 1 fi echo "Both bin/camel-x64.exe and bin/camel-arm64.exe found in archive" + - name: Verify reproducible native executables and WinGet archive + run: | + HASH_FILE="$RUNNER_TEMP/winget-first-build.sha256" + sha256sum \ + tooling/camel-exe/target/camel-x64.exe \ + tooling/camel-exe/target/camel-arm64.exe \ + dsl/camel-jbang/camel-launcher/target/camel-launcher-*-winget-bin.zip \ + > "$HASH_FILE" + + # 'clean verify' wipes target, so keep the first-build outputs around; on a mismatch the + # differing byte offsets and zip entry listing say far more than a bare FAILED. + FIRST_BUILD="$RUNNER_TEMP/winget-first-build" + mkdir -p "$FIRST_BUILD" + cp tooling/camel-exe/target/camel-x64.exe \ + tooling/camel-exe/target/camel-arm64.exe \ + dsl/camel-jbang/camel-launcher/target/camel-launcher-*-winget-bin.zip "$FIRST_BUILD" + + mvn -B -P apache-snapshots \ + -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ + -Dcamel.exe.build=true -DskipTests + + if ! sha256sum --check "$HASH_FILE"; then + for ARCH in x64 arm64; do + echo "=== differing bytes in camel-${ARCH}.exe (first 40) ===" + cmp -l "$FIRST_BUILD/camel-${ARCH}.exe" \ + "tooling/camel-exe/target/camel-${ARCH}.exe" | head -40 || true + done + # 'unzip -v' lists each entry's CRC and timestamp, so the diff distinguishes an entry + # whose contents changed from one that merely got a new modification time. + echo "=== differing WinGet archive entries ===" + ZIP=$(find dsl/camel-jbang/camel-launcher/target -maxdepth 1 -name '*-winget-bin.zip' -print -quit) + diff <(unzip -v "$FIRST_BUILD"/*-winget-bin.zip) <(unzip -v "$ZIP") || true + + # The launcher JAR is itself an archive, so a CRC change above says nothing about which of + # its thousands of entries moved. Unpack both and diff one level down. + echo "=== differing entries inside the launcher JAR ===" + unzip -qo "$FIRST_BUILD"/*-winget-bin.zip '*/bin/camel-launcher-*.jar' -d "$RUNNER_TEMP/jar-first" + unzip -qo "$ZIP" '*/bin/camel-launcher-*.jar' -d "$RUNNER_TEMP/jar-second" + diff <(unzip -v "$(find "$RUNNER_TEMP/jar-first" -name '*.jar' -print -quit)") \ + <(unzip -v "$(find "$RUNNER_TEMP/jar-second" -name '*.jar' -print -quit)") \ + | head -60 || true + exit 1 + fi + - name: Verify WinGet archive is not deployed by Maven + run: | + MAVEN_REPO="$RUNNER_TEMP/winget-maven-repo" + mvn -B -P apache-snapshots \ + -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher deploy \ + -Dcamel.exe.build=true -DskipTests \ + -DaltSnapshotDeploymentRepository="winget-check::file://$MAVEN_REPO" + + # Matches the ZIP and every sidecar Maven may generate alongside it + # (.sha1, .md5, .sha256, .sha512, .asc), not just the two seen locally. + DEPLOYED_WINGET_FILES=$(find "$MAVEN_REPO" -type f -name '*-winget-bin.zip*' -print) + if [ -n "$DEPLOYED_WINGET_FILES" ]; then + echo "ERROR: Maven deployed the local-only WinGet payload:" + echo "$DEPLOYED_WINGET_FILES" + exit 1 + fi 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 e70c63e1c5506..a5eb1528120bd 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 @@ -86,24 +86,23 @@ The Gentoo `java-config` auto-detection and the IBM AIX `$JAVA_HOME/jre/sh/java` removed; set `JAVA_HOME` or `JAVACMD` explicitly on those platforms. `JAVA_OPTS` handling is unchanged: when unset, the default `-Xmx512m` heap is applied. -==== Native `camel.exe` in the launcher distribution +==== Native `camel.exe` in the WinGet package -The `camel-launcher` distribution now ships two native Windows executables: -`bin/camel-x64.exe` (x86_64) and `bin/camel-arm64.exe` (aarch64), alongside -`bin/camel.bat`. Both are thin bootstraps: they forward all arguments to the -adjacent `camel.bat` (preserving spaces and Unicode) and return its exit code. -They exist so package managers that require a genuine executable (such as WinGet) -work correctly. Users may continue to invoke `bin\camel.bat`; all three behave -identically. +The WinGet package now ships two native Windows executables: `bin/camel-x64.exe` +(x86_64) and `bin/camel-arm64.exe` (aarch64), alongside `bin/camel.bat`. Both are +thin bootstraps: they forward all arguments to the adjacent `camel.bat` (preserving +spaces and Unicode) and return its exit code. WinGet selects the executable matching +the host architecture and exposes it as `camel.exe`. + +The public `camel-launcher--bin.zip` and `.tar.gz` archives do not contain +these WinGet-specific executables. Windows users of those archives should continue +to invoke `bin\camel.bat`. The native executables are now cross-compiled from any host OS using https://github.com/mstorsjo/llvm-mingw[clang/llvm-mingw], replacing the previous MSVC-only build that required a Windows host. The build is activated by -`-Dcamel.exe.build=true` or `-Prelease` and requires `llvm-mingw` on `PATH`. - -If you have tooling that referenced `bin/camel.exe`, update it to use -`bin/camel-x64.exe` (for x86_64 Windows) or `bin/camel-arm64.exe` (for ARM64 -Windows). +`-Dcamel.exe.build=true` and requires `llvm-mingw` on `PATH`. Release builds get this +from `maven-release-plugin`, which passes it alongside `-Prelease`. ==== OpenTelemetry agent export target changes @@ -154,7 +153,7 @@ Installation is always per-user and never requires elevation or `sudo`: prints guidance instead. * Windows (`install.ps1`) installs under `%LOCALAPPDATA%\Apache Camel\cli\versions\` and activates it via a `camel.cmd` shim at `%LOCALAPPDATA%\Apache Camel\bin\camel.cmd` that delegates - to the staged `camel-x64.exe` or `camel-arm64.exe` (auto-detected). The bin directory is added + to the staged `camel.bat`. The bin directory is added once, case-insensitively, to the current user's `PATH`; the machine `PATH` is never modified. Previously installed version directories are left in place after an upgrade or downgrade and diff --git a/docs/user-manual/modules/ROOT/pages/release-guide.adoc b/docs/user-manual/modules/ROOT/pages/release-guide.adoc index a4ae6bac35bfc..1eb3a1cf7b563 100644 --- a/docs/user-manual/modules/ROOT/pages/release-guide.adoc +++ b/docs/user-manual/modules/ROOT/pages/release-guide.adoc @@ -256,6 +256,23 @@ Complete the following steps to create a new Camel release: $ ./mvnw release:perform -Prelease +. Stage the WinGet payload in the Apache development distribution repository: + +* `release:perform` builds the non-attached WinGet ZIP in its release checkout. Prepare the +candidate with the same candidate number used in the vote: + + cd ${CAMEL_ROOT_DIR}/etc/scripts + ./stage-winget-distro.sh \ + ${CAMEL_ROOT_DIR}/target/checkout/dsl/camel-jbang/camel-launcher/target/camel-launcher--winget-bin.zip + +* Review the printed `svn status`, commit the working copy only after checking the ZIP, `.asc`, +and `.sha512`, and include the resulting `dist/dev` candidate URL together with the Nexus staging +repository URL in the vote email. The WinGet ZIP is not present in the Nexus staging repository. + +* The script prints the candidate's SHA-512 digest. **Include it in the vote email**: promotion +requires it, because the candidate number on its own cannot distinguish the approved candidate from +a superseded one (each RC directory carries its own self-consistent `.sha512` and `.asc`). + . Close the Apache staging repository: @@ -385,7 +402,24 @@ This will release the artifacts. . Copy distribution to Apache website: cd ${CAMEL_ROOT_DIR}/etc/scripts - ./release-distro.sh + ./release-distro.sh + +* The script exports the voted WinGet ZIP, signature, and SHA-512 checksum from `dist/dev`, verifies +them, and adds the exact files to the local `dist/release` working copy. It does not rebuild the ZIP. +Review and commit the combined working copy using the commands printed by the script. + +* `` is the digest from the vote email. Promotion aborts if the exported candidate +does not match it, which is what stops a superseded RC from being promoted by mistake. + +* After the release is available from +`https://archive.apache.org/dist/camel/apache-camel//`, run the JReleaser package +preparation. WinGet preparation fails until the archived ZIP is available and byte-identical to the +approved local ZIP. + +* Remove the promoted candidate after the release copy is committed: + + svn rm https://dist.apache.org/repos/dist/dev/camel/apache-camel/-rc \ + -m "Remove promoted Apache Camel WinGet candidate" . Copy SBOMs to Apache website: diff --git a/dsl/camel-jbang/camel-launcher/README.md b/dsl/camel-jbang/camel-launcher/README.md index f2d1bde1bd78b..23e782f3526fa 100644 --- a/dsl/camel-jbang/camel-launcher/README.md +++ b/dsl/camel-jbang/camel-launcher/README.md @@ -16,16 +16,19 @@ This will create: 1. A self-executing JAR (`camel-launcher-.jar`) in the `target` directory using Spring Boot's nested JAR structure 2. Distribution archives (`camel-launcher--bin.zip` and `camel-launcher--bin.tar.gz`) in the `target` directory -When `-Dcamel.exe.build=true` is enabled, the distribution archives also include native Windows bootstraps built by +When `-Dcamel.exe.build=true` is enabled, Maven also creates a WinGet-only archive, +`camel-launcher--winget-bin.zip`, containing the native Windows bootstraps built by [`tooling/camel-exe`](../../../tooling/camel-exe): `bin/camel-x64.exe` and `bin/camel-arm64.exe`. +The assembly uses `attach=false`, so the ZIP remains in this module's `target` directory for the +Apache distribution release flow and is not installed or deployed to a Maven repository. Release builds use llvm-mingw to cross-compile both executables on Linux. The launcher module verifies during `verify` -that both files are staged and present in the assembled ZIP: +that both files are staged, absent from the public archives, and present in the WinGet ZIP: ```bash mvn -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher -am verify -Dcamel.exe.build=true ``` -To build and test only the native bootstrap: +To build and test only the native bootstraps: ```bash mvn -pl buildingtools,tooling/camel-exe -am verify -Dcamel.exe.build=true @@ -65,12 +68,10 @@ java -jar camel-launcher-.jar run hello.java # On Windows bin\camel.bat [command] [options] - bin\camel-x64.exe [command] [options] - bin\camel-arm64.exe [command] [options] ``` - The native executables and `camel.bat` behave identically on Windows; the native executables exist for package managers - (such as WinGet) that require a genuine executable command. + The native executables are confined to the WinGet-only archive. WinGet selects the executable matching the host + architecture and exposes it as `camel.exe`. ## Benefits @@ -107,3 +108,47 @@ camel-launcher-.jar ``` This structure provides better performance and reliability compared to traditional fat JARs where all classes are merged together. + +## Packaging (JReleaser) + +The `jreleaser.yml` in this module configures distribution packaging for Homebrew, SDKMAN, +WinGet, Scoop, and Chocolatey. Custom templates live under +`src/jreleaser/distributions/camel-cli//`. + +### Homebrew Maven Central URL convention + +Homebrew's `FormulaAudit::Urls` rubocop +([source](https://docs.brew.sh/rubydoc/RuboCop/Cop/UrlHelper.html)) requires Maven Central +artifacts to use the `search.maven.org` redirector URL instead of `repo1.maven.org`: + +``` +# Required by Homebrew: +https://search.maven.org/remotecontent?filepath=org/apache/camel/camel-launcher/VERSION/FILE + +# NOT accepted (triggers brew audit failure): +https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/VERSION/FILE +``` + +Both resolve to the same artifact — `search.maven.org/remotecontent` simply redirects to +`repo1.maven.org/maven2`. This rule applies **only to Homebrew**. SDKMAN, Scoop, and Chocolatey +use `repo1.maven.org` directly. WinGet uses the immutable Apache archive URL because its dedicated +ZIP is an Apache distribution payload and is not published to Maven Central. + +Because that redirector URL carries a query string, the templates render every URL with Mustache's +triple-brace form (`{{{distributionUrl}}}`). JReleaser HTML-escapes the ordinary double-brace form, +which rewrites the `=` in `?filepath=` as `=` and produces a URL Homebrew cannot download. The +other packagers use path-only URLs today and would not notice the difference, but they use the +unescaped form too so that adding a query parameter later cannot silently break them. + +### Native bootstrap executables + +The dedicated `camel-launcher--winget-bin.zip` ships `camel-x64.exe` and +`camel-arm64.exe` for WinGet, which requires a genuine portable executable per architecture +(see the WinGet `installer.yaml.tpl` override). The public ZIP and TAR archives do not contain +these WinGet-specific executables. + +Before JReleaser prepares the WinGet manifest, `camel-package.sh` downloads the versioned Apache +archive URL and fails unless its bytes exactly match the local WinGet ZIP. + +Chocolatey and Scoop use `camel.bat` as their entry point instead (it needs only a JRE, so it is +architecture-neutral and requires no per-architecture binary). diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml b/dsl/camel-jbang/camel-launcher/jreleaser.yml new file mode 100644 index 0000000000000..a3b08d07a841e --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -0,0 +1,163 @@ +# +# 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. +# + +# NOTE: field names and packager semantics were checked against the pinned JReleaser Maven +# plugin version in pom.xml before authoring this file. Key findings that shaped this +# configuration: +# - `type: JAVA_BINARY` (not BINARY): this distribution is a Java application, so using +# JReleaser's Java distribution type keeps packager behavior aligned with that runtime. +# formula.rb.tpl explicitly declares `depends_on "openjdk"`; it is unpinned because a +# numbered formula can be retired from homebrew-core over time, while unpinned resolves +# to a currently installable JDK that still satisfies this project's Java 17+ minimum. +# The launcher's own JAVACMD/JAVA_HOME/PATH lookup order means this Homebrew dependency +# is a self-contained fallback, not the enforced runtime, so pinning buys nothing. +# - SDKMAN has no boolean "default" flag; `command: MAJOR` publishes and sets the +# new version as the candidate default, `command: MINOR` publishes without +# moving the default. SDKMAN produces no local `prepare` output (API-only), so +# channel-specific MAJOR/MINOR selection only matters once `publish` is implemented. +# - `{{ Env.X }}` reads real OS environment variables (java.lang.System#getenv), +# not Maven -D system properties, so the wrappers export CAMEL_PKG_* before +# invoking JReleaser. Enum-typed fields (active, sdkman.command) are parsed +# before Mustache templates resolve, so `{{ Env.X }}` cannot be used on them. +# - Artifact paths are relative to the repo root: the JReleaser Maven plugin's basedir +# defaults to the multi-module reactor root, not this file's directory, unlike the +# standalone JReleaser CLI. +# - Per-channel packager selection (e.g. excluding `scoop` on the `lts` channel) +# is done via the jreleaser:prepare/jreleaser:package Mojo's own +# `-Djreleaser.packagers` include filter, not via a templated `active` field. +# - Each packager overrides `downloadUrl`; the standard launcher archives use +# Maven Central, while the dedicated WinGet payload uses the Apache archive. +# JReleaser's default download URL points at a GitHub release asset, which +# this project does not publish artifacts to. +# - `project.vendor` and each distribution's `tags` are required fields in +# practice (validation fails without them) despite docs implying defaults. +# This file renders no package content itself; per-packager overrides live under +# src/jreleaser/distributions/camel-cli//. +# +# KNOWN GAP: `--channel stable --lts-line X.Y` is specified to also publish a +# versioned Homebrew formula (`camel@X.Y`) alongside the unversioned `camel` +# formula. This file declares only one `brew` packager per distribution, so today +# it only ever produces the single formula selected by CAMEL_PKG_BREW_FORMULA. +# Producing both formulae from one release depends on the same publish-destination +# decision `publish` itself is waiting on (see camel-package.sh) - whether the +# versioned formula goes to this project's own tap or homebrew-core affects how +# it can be produced alongside the unversioned one. Until that's settled, use a +# separate `--channel lts --lts-line X.Y` run to produce the versioned formula +# on its own. +# +# As of the pinned JReleaser plugin version, Homebrew's "AT" naming convention is not +# applied to `formulaName` itself: a literal "camel@4.20" renders an invalid Ruby class +# (`class Camel@4.20 < Formula`) and a wrong output filename (`20.rb` instead of +# `camel@4.20.rb`). `camel-package.sh` works around this by passing +# the already-converted class name (e.g. "CamelAT420") as CAMEL_PKG_BREW_FORMULA, then +# renaming JReleaser's kebab-cased output file (e.g. "camel-at-420.rb") to the real +# "camel@4.20.rb" after packaging. + +project: + name: camel-cli + description: Apache Camel CLI + links: + homepage: https://camel.apache.org/ + authors: + - The Apache Camel Team + license: Apache-2.0 + vendor: The Apache Software Foundation + icons: + - url: https://raw.githubusercontent.com/apache/camel/main/docs/img/logo64-d.png + width: 64 + height: 64 + primary: true + # width/height are mandatory on every icon, not just the primary one: JReleaser fails + # validation in `jreleaser:config` with "project.icons[n].width must not be null" otherwise. + # These are the real dimensions of logo-medium-d.png. + - url: https://raw.githubusercontent.com/apache/camel/main/docs/img/logo-medium-d.png + width: 500 + height: 145 + languages: + java: + groupId: org.apache.camel + version: 17 + +distributions: + # The public distribution deliberately excludes the native Windows bootstraps. They are only + # needed by WinGet and live in the dedicated camel-cli-winget distribution below. + camel-cli: + type: JAVA_BINARY + tags: + - cli + executable: + name: camel + # The zip/tar bin/ directory ships `camel.sh` (Unix) and `camel.bat` (Windows) per + # src/main/assembly/bin.xml; without this, `distributionExecutableUnix` + # defaults to the bare distribution name ("camel"), which does not exist on disk and + # produces a broken symlink in the Homebrew/Scoop/Chocolatey templates. Windows already + # defaults correctly to `bat`. + unixExtension: sh + artifacts: + - path: "dsl/camel-jbang/camel-launcher/target/camel-launcher-{{projectVersion}}-bin.zip" + brew: + active: RELEASE + formulaName: "{{ Env.CAMEL_PKG_BREW_FORMULA }}" + # Homebrew's FormulaAudit::Urls cop requires the search.maven.org redirector + # for Maven Central artifacts (not repo1.maven.org); see url_helper.rb in + # Homebrew/brew. The other packagers keep repo1.maven.org (no such rule). + downloadUrl: "https://search.maven.org/remotecontent?filepath=org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + # templateDirectory defaults to "src/jreleaser/distributions/#{distribution.name}/brew" + # resolved against the JReleaser basedir, which is the multi-module reactor root (see + # NOTE above), not this module's directory. Empirically confirmed (jreleaser:prepare + # trace.log): without this override the module's formula.rb.tpl is silently ignored + # and JReleaser falls back to its embedded default template. + templateDirectory: "dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew" + extraProperties: + # Non-empty only for versioned formulae (e.g. "camel@4.20"); used by + # formula.rb.tpl as a Mustache truthiness check (empty string == falsy) + # to add `keg_only :versioned_formula` and its PATH caveat. + versionedFormula: "{{ Env.CAMEL_PKG_BREW_VERSIONED }}" + sdkman: + active: RELEASE + candidate: camel + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + # command (MAJOR/MINOR) defaults to MAJOR here (correct for `stable`); see the + # NOTE above on why per-channel selection only matters once `publish` is implemented. + scoop: + active: RELEASE + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + templateDirectory: "dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/scoop" + chocolatey: + active: RELEASE + packageName: camel + iconUrl: 'https://raw.githubusercontent.com/apache/camel/main/docs/img/logo64-d.png' + remoteBuild: true + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + templateDirectory: "dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey" + camel-cli-winget: + type: JAVA_BINARY + tags: + - cli + executable: + name: camel + unixExtension: sh + artifacts: + - path: "dsl/camel-jbang/camel-launcher/target/camel-launcher-{{projectVersion}}-winget-bin.zip" + winget: + active: RELEASE + downloadUrl: "https://archive.apache.org/dist/camel/apache-camel/{{projectVersion}}/{{artifactFile}}" + # Keep the templates under camel-cli so both distributions share the existing package identity. + templateDirectory: "dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget" + package: + identifier: Apache.CamelCLI + name: Camel CLI diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 993bff6f05c79..c023ff1ded30c 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -55,6 +55,7 @@ 1.22.0 ${slf4j-version} 0.29.0 + 1.25.0 @@ -64,7 +65,7 @@ single flat classpath. The generate plugin drags in commonmark 0.21.0 (via openapi-generator), while the tui plugin (via tamboui-markdown) needs commonmark 0.28.0 and its renderer.markdown classes that do not exist in 0.21.0. Without this pin Maven mediation keeps the older 0.21.0 - core next to the 0.28.0 extensions, which makes the TUI markdown help throw NoClassDefFoundError. + core next to the 0.28.0 extensions, which makes the TUI Markdown help throw NoClassDefFoundError. Pin the core to 0.28.0 so it matches the commonmark-ext-* modules; openapi-generator only uses the stable Parser/HtmlRenderer APIs, which are unchanged in 0.28.0. --> @@ -329,14 +330,57 @@ + + + org.jreleaser + jreleaser-maven-plugin + ${jreleaser-plugin-version} + false + + ${project.basedir}/jreleaser.yml + true + + + + + com.mycila + license-maven-plugin + + + + + src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl + src/jreleaser/distributions/camel-cli/winget/installer.yaml.tpl + src/jreleaser/distributions/camel-cli/winget/locale.yaml.tpl + src/jreleaser/distributions/camel-cli/winget/version.yaml.tpl + src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl + src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyuninstall.ps1.tpl + src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl + + + + + include-camel-exe @@ -364,6 +408,25 @@ + + org.apache.maven.plugins + maven-assembly-plugin + + + assemble-winget-bin + package + + single + + + false + + src/main/assembly/winget-bin.xml + + + + + org.apache.maven.plugins maven-dependency-plugin diff --git a/dsl/camel-jbang/camel-launcher/src/install/install.ps1 b/dsl/camel-jbang/camel-launcher/src/install/install.ps1 index 5b8fad38193e9..c57fc6b3340db 100644 --- a/dsl/camel-jbang/camel-launcher/src/install/install.ps1 +++ b/dsl/camel-jbang/camel-launcher/src/install/install.ps1 @@ -139,8 +139,7 @@ function Test-ArchiveEntry { $zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) try { $roots = New-Object 'System.Collections.Generic.HashSet[string]' - $foundX64 = $false - $foundArm64 = $false + $foundBatchLauncher = $false foreach ($entry in $zip.Entries) { $name = $entry.FullName if ([string]::IsNullOrEmpty($name)) { @@ -159,11 +158,8 @@ function Test-ArchiveEntry { Fail "archive contains a symbolic link or reparse point entry, which is not allowed" } [void]$roots.Add($segments[0]) - if ($normalized -eq "$expectedRoot/bin/camel-x64.exe") { - $foundX64 = $true - } - if ($normalized -eq "$expectedRoot/bin/camel-arm64.exe") { - $foundArm64 = $true + if ($normalized -eq "$expectedRoot/bin/camel.bat") { + $foundBatchLauncher = $true } } if ($roots.Count -ne 1) { @@ -172,11 +168,8 @@ function Test-ArchiveEntry { if (-not $roots.Contains($expectedRoot)) { Fail "archive top-level directory does not match expected version: $($roots -join ',')" } - if (-not $foundX64) { - Fail "archive is missing bin\camel-x64.exe" - } - if (-not $foundArm64) { - Fail "archive is missing bin\camel-arm64.exe" + if (-not $foundBatchLauncher) { + Fail "archive is missing bin\camel.bat" } } finally { $zip.Dispose() @@ -186,9 +179,9 @@ function Test-ArchiveEntry { # Runs the freshly staged upstream launcher; a nonzero exit (e.g. no Java 17+ available) aborts the # install and leaves the previously active installation untouched. function Test-StagedLauncher { - param([string] $ExePath) + param([string] $LauncherPath) try { - & $ExePath 'version' *> $null + & $LauncherPath 'version' *> $null } catch { Fail "staged launcher failed verification (Java 17+ required)" } @@ -208,9 +201,8 @@ function Set-CamelShim { Move-Item -LiteralPath $StagedRoot -Destination $targetDir New-Item -ItemType Directory -Force -Path $BinDir | Out-Null - $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' } - $exePath = Join-Path $targetDir "bin\camel-$arch.exe" - $shimContent = "@echo off`r`n`"$exePath`" %*`r`nexit /b %ERRORLEVEL%`r`n" + $launcherPath = Join-Path $targetDir 'bin\camel.bat' + $shimContent = "@echo off`r`ncall `"$launcherPath`" %*`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. @@ -286,9 +278,8 @@ try { Expand-Archive -LiteralPath $archiveFile -DestinationPath $extractDir -Force $stagedRoot = Join-Path $extractDir "camel-launcher-$resolvedVersion" - $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' } - $stagedExe = Join-Path $stagedRoot "bin\camel-$arch.exe" - Test-StagedLauncher -ExePath $stagedExe + $stagedLauncher = Join-Path $stagedRoot 'bin\camel.bat' + Test-StagedLauncher -LauncherPath $stagedLauncher Set-CamelShim -Version $resolvedVersion -StagedRoot $stagedRoot Add-UserPath -Dir $BinDir 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 new file mode 100755 index 0000000000000..a49369c59efde --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -0,0 +1,339 @@ +#!/usr/bin/env bash +# +# 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. +# + +set -euo pipefail + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +MODULE_DIR=$(CDPATH='' cd -- "$SCRIPT_DIR/../../.." && pwd) +SUPPORTED_LTS="$SCRIPT_DIR/../supported-lts.yml" + +# Tests may point this at a synthetic LTS allowlist so expiry-date assertions don't ride on +# real production supportEnds dates (mirrors CAMEL_PACKAGE_TEST_VERSION further below). +if [ -n "${CAMEL_PACKAGE_TEST_SUPPORTED_LTS:-}" ]; then + if [ "${CAMEL_PACKAGE_TEST_MODE:-}" != "true" ]; then + echo "Error: CAMEL_PACKAGE_TEST_SUPPORTED_LTS requires CAMEL_PACKAGE_TEST_MODE=true." 1>&2 + exit 2 + fi + SUPPORTED_LTS="$CAMEL_PACKAGE_TEST_SUPPORTED_LTS" +fi + +usage() { + echo "Usage: camel-package.sh --channel [--lts-line X.Y] [--print-plan]" 1>&2 + exit 2 +} + +SUBCOMMAND="" +CHANNEL="" +LTS_LINE="" +PRINT_PLAN=0 + +[ $# -ge 1 ] || usage +SUBCOMMAND="$1"; shift +case "$SUBCOMMAND" in + prepare|publish) ;; + *) echo "Error: unknown subcommand '$SUBCOMMAND'." 1>&2; usage ;; +esac + +# Each value-taking option checks for its value before shifting past it: a bare +# `shift 2` with only one argument left fails, and `set -e` would then kill the script +# silently with exit 1 instead of reporting the usage error. +require_value() { + [ $# -ge 2 ] || { echo "Error: '$1' requires a value." 1>&2; usage; } +} + +while [ $# -gt 0 ]; do + case "$1" in + --channel) require_value "$@"; CHANNEL="$2"; shift 2 ;; + --lts-line) require_value "$@"; LTS_LINE="$2"; shift 2 ;; + --print-plan) PRINT_PLAN=1; shift ;; + *) echo "Error: unknown argument '$1'." 1>&2; usage ;; + esac +done + +case "$CHANNEL" in + stable|lts) ;; + *) echo "Error: --channel must be 'stable' or 'lts' (got '$CHANNEL')." 1>&2; usage ;; +esac + +if [ "$CHANNEL" = "lts" ] && [ -z "$LTS_LINE" ]; then + echo "Error: --channel lts requires --lts-line X.Y." 1>&2 + usage +fi + +# Validate --lts-line against the allowlist and its support-end date. +if [ -n "$LTS_LINE" ]; then + if [ ! -r "$SUPPORTED_LTS" ]; then + echo "Error: supported LTS metadata is not readable: $SUPPORTED_LTS" 1>&2 + exit 1 + fi + if ! grep -Eq '^[[:space:]]*-[[:space:]]+line:' "$SUPPORTED_LTS"; then + echo "Error: supported LTS metadata is malformed: $SUPPORTED_LTS" 1>&2 + exit 1 + fi + today=$(date +%F) + supported_ends=$(awk -v line="$LTS_LINE" ' + $1 == "-" && $2 == "line:" { cur = $3; gsub(/"/, "", cur) } + $1 == "supportEnds:" && cur == line { v = $2; gsub(/"/, "", v); print v; exit } + ' "$SUPPORTED_LTS") + if [ -z "$supported_ends" ]; then + echo "Error: '$LTS_LINE' is not a supported LTS line (see supported-lts.yml)." 1>&2 + exit 2 + fi + # ISO-8601 dates compare correctly as strings. + if expr "$today" \> "$supported_ends" > /dev/null; then + echo "Error: LTS line '$LTS_LINE' support ended on $supported_ends." 1>&2 + exit 2 + fi +fi + +# --- Channel -> packaging plan --- +if [ "$CHANNEL" = "stable" ]; then + PACKAGERS="brew,sdkman,winget,scoop,chocolatey" + BREW_FORMULA="camel" + BREW_CLASS="Camel" + SDKMAN_DEFAULT="true" + BREW_LTS_FORMULA="" + WEBSITE_LATEST="true" + [ -n "$LTS_LINE" ] && BREW_LTS_FORMULA="camel@$LTS_LINE" +else + # Homebrew's own versioned-formula convention names the *file* "camel@X.Y.rb" but the + # Ruby *class* "CamelATxy" (dot removed) - e.g. real homebrew-core "python@3.11.rb" + # contains "class PythonAT311". As of the pinned JReleaser plugin version in pom.xml, + # JReleaser does not apply this convention itself: + # a literal formulaName "camel@4.20" renders invalid Ruby (`class Camel@4.20 < Formula`) + # and a wrong output filename ("20.rb"). So BREW_CLASS below is + # passed as formulaName (giving valid Ruby), and JReleaser's output file (itself + # kebab-cased from that class name, e.g. "camel-at-420.rb") + # is renamed to the real "camel@X.Y.rb" after packaging - see the rename step below the + # mvn invocation. + PACKAGERS="brew,sdkman,winget,chocolatey" + BREW_FORMULA="camel@$LTS_LINE" + BREW_CLASS="CamelAT$(echo "$LTS_LINE" | tr -d '.')" + SDKMAN_DEFAULT="false" + BREW_LTS_FORMULA="" + WEBSITE_LATEST="false" +fi + +if [ "$PRINT_PLAN" -eq 1 ]; then + echo "CHANNEL=$CHANNEL" + echo "LTS_LINE=$LTS_LINE" + echo "PACKAGERS=$PACKAGERS" + echo "SDKMAN_CANDIDATE=camel" + echo "SDKMAN_DEFAULT=$SDKMAN_DEFAULT" + echo "WEBSITE_VERSION_MANIFEST=true" + echo "WEBSITE_LATEST=$WEBSITE_LATEST" + [ -n "$BREW_LTS_FORMULA" ] && echo "BREW_LTS_FORMULA=$BREW_LTS_FORMULA" + [ -n "$BREW_FORMULA" ] && echo "BREW_FORMULA=$BREW_FORMULA" + [ -n "$BREW_CLASS" ] && echo "BREW_CLASS=$BREW_CLASS" + exit 0 +fi + +if [ "$SUBCOMMAND" = "publish" ]; then + # Publication is intentionally unimplemented: where each packager's artifacts get + # pushed (e.g. Homebrew to the project's own tap vs. homebrew-core) is a decision + # for the Apache Camel PMC, not something this script should default on its own. + # This also covers the Homebrew dual-formula gap noted in jreleaser.yml above - + # both are blocked on that same publish-destination decision. + echo "Error: 'publish' is not yet implemented; awaiting a PMC decision on publish destinations." 1>&2 + exit 2 +fi + +# --- prepare: no remote mutation, no credentials --- + +# Resolve the release version. Production always reads Maven's project.version; tests/CI may +# override it, but only with both CAMEL_PACKAGE_TEST_MODE=true and CAMEL_PACKAGE_TEST_VERSION set, +# so production can never accidentally skip the real Maven version. +if [ -n "${CAMEL_PACKAGE_TEST_VERSION:-}" ]; then + if [ "${CAMEL_PACKAGE_TEST_MODE:-}" != "true" ]; then + echo "Error: CAMEL_PACKAGE_TEST_VERSION requires CAMEL_PACKAGE_TEST_MODE=true." 1>&2 + exit 2 + fi + PROJECT_VERSION="$CAMEL_PACKAGE_TEST_VERSION" +else + PROJECT_VERSION=$(mvn -q -B -ntp -f "$MODULE_DIR/pom.xml" org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate \ + -Dexpression=project.version -DforceStdout) +fi + +case "$PROJECT_VERSION" in + *-SNAPSHOT) + echo "Error: refusing to prepare packages for a snapshot version '$PROJECT_VERSION'." 1>&2 + exit 2 + ;; +esac + +if [ -n "${CAMEL_PACKAGE_TEST_WINGET_REMOTE:-}" ] && [ "${CAMEL_PACKAGE_TEST_MODE:-}" != "true" ]; then + echo "Error: CAMEL_PACKAGE_TEST_WINGET_REMOTE requires CAMEL_PACKAGE_TEST_MODE=true." 1>&2 + exit 2 +fi + +# Locate the release artifacts and canonical website installer sources. +TAR="$MODULE_DIR/target/camel-launcher-$PROJECT_VERSION-bin.tar.gz" +ZIP="$MODULE_DIR/target/camel-launcher-$PROJECT_VERSION-bin.zip" +WINGET_ZIP="$MODULE_DIR/target/camel-launcher-$PROJECT_VERSION-winget-bin.zip" +WINGET_URL="https://archive.apache.org/dist/camel/apache-camel/$PROJECT_VERSION/$(basename -- "$WINGET_ZIP")" +INSTALL_SH_SRC="$MODULE_DIR/src/install/install.sh" +INSTALL_PS1_SRC="$MODULE_DIR/src/install/install.ps1" + +if [ ! -f "$TAR" ]; then + echo "Error: release TAR not found: $TAR" 1>&2 + exit 1 +fi +if [ ! -f "$ZIP" ]; then + echo "Error: release ZIP not found: $ZIP" 1>&2 + exit 1 +fi +if [ ! -f "$WINGET_ZIP" ]; then + echo "Error: WinGet release ZIP not found: $WINGET_ZIP" 1>&2 + exit 1 +fi +if [ ! -f "$INSTALL_SH_SRC" ]; then + echo "Error: installer source not found: $INSTALL_SH_SRC" 1>&2 + exit 1 +fi +if [ ! -f "$INSTALL_PS1_SRC" ]; then + echo "Error: installer source not found: $INSTALL_PS1_SRC" 1>&2 + exit 1 +fi + +archived_winget=$(mktemp) +cleanup_archived_winget() { + rm -f "$archived_winget" +} +trap cleanup_archived_winget EXIT + +if [ -n "${CAMEL_PACKAGE_TEST_WINGET_REMOTE:-}" ]; then + cp "$CAMEL_PACKAGE_TEST_WINGET_REMOTE" "$archived_winget" +elif ! curl -fsSL -o "$archived_winget" "$WINGET_URL"; then + echo "Error: archived WinGet payload is not available at $WINGET_URL" 1>&2 + exit 1 +fi + +if ! cmp -s "$WINGET_ZIP" "$archived_winget"; then + echo "Error: local WinGet ZIP does not match the archived WinGet payload at $WINGET_URL" 1>&2 + exit 1 +fi + +cleanup_archived_winget +trap - EXIT + +# Recreate only the prepared website staging directory (leave the rest of target/jreleaser alone). +WEBSITE_DIR="$MODULE_DIR/target/jreleaser/website" +rm -rf "$WEBSITE_DIR" +mkdir -p "$WEBSITE_DIR" + +cp -p "$INSTALL_SH_SRC" "$WEBSITE_DIR/install.sh" +cp -p "$INSTALL_PS1_SRC" "$WEBSITE_DIR/install.ps1" + +if ! cmp -s "$INSTALL_SH_SRC" "$WEBSITE_DIR/install.sh"; then + echo "Error: copied install.sh does not match its source." 1>&2 + exit 1 +fi +if ! cmp -s "$INSTALL_PS1_SRC" "$WEBSITE_DIR/install.ps1"; then + echo "Error: copied install.ps1 does not match its source." 1>&2 + exit 1 +fi + +if ! java "$MODULE_DIR/src/jreleaser/java/WebsiteManifestGenerator.java" \ + --version "$PROJECT_VERSION" --tar "$TAR" --zip "$ZIP" \ + --output "$WEBSITE_DIR/camel-cli" \ + --latest "$WEBSITE_LATEST"; then + echo "Error: website manifest generation failed." 1>&2 + exit 1 +fi + +# jreleaser.yml reads the Homebrew formula name via `{{ Env.CAMEL_PKG_BREW_FORMULA }}`, +# which becomes the generated Ruby class name directly (`class {{brewFormulaName}}`), so +# this must be BREW_CLASS (a valid Ruby class name), not the human-facing BREW_FORMULA. +# JReleaser's `Env.` template prefix resolves real OS environment variables +# (java.lang.System#getenv), not Maven -D system properties, so this must be +# exported rather than passed as -D. +export CAMEL_PKG_BREW_FORMULA="$BREW_CLASS" + +# Non-empty only for a versioned formula (e.g. "camel@4.20"); formula.rb.tpl uses +# this to add `keg_only :versioned_formula` and its PATH caveat. +case "$BREW_FORMULA" in + *@*) CAMEL_PKG_BREW_VERSIONED="$BREW_FORMULA" ;; + *) CAMEL_PKG_BREW_VERSIONED="" ;; +esac +export CAMEL_PKG_BREW_VERSIONED + +# `-Djreleaser.dry.run=true` below never performs a network call, so a placeholder +# satisfies JReleaser's config validation (it requires a non-blank release token) +# without requiring a real credential. Never overrides a token the caller already set. +: "${JRELEASER_GITHUB_TOKEN:=dry-run-placeholder}" +export JRELEASER_GITHUB_TOKEN + +# `-Djreleaser.distributions` / `-Djreleaser.packagers` are the JReleaser Maven +# plugin's own include filters (confirmed via `mvn help:describe` on the pinned +# 1.25.0 plugin), used to select which packagers run for this channel; e.g. `lts` +# excludes `scoop` by omitting it from $PACKAGERS. +# +# `-Djreleaser.project.version` is NOT a real parameter of this Mojo (confirmed via +# `mvn help:describe -Dgoal=prepare/config/package -Ddetail=true`: none of them expose it). +# JReleaser always reads the real Maven project.version from the POM itself, regardless of +# $PROJECT_VERSION above (which only governs our own SNAPSHOT guard, artifact lookup, and the +# website manifest). What actually gates every packager above (`active: RELEASE`) is JReleaser's +# own snapshot detection, which matches the *real* POM version against +# `jreleaser.project.snapshot.pattern` (default `.*-SNAPSHOT`) - so test-mode runs from +# a SNAPSHOT checkout would otherwise skip every packager even once our own guard has been +# satisfied via a CAMEL_PACKAGE_TEST_VERSION override. In that one case, override the pattern +# to something that can never match, so JReleaser treats the real POM version as a release too. +SNAPSHOT_PATTERN_ARGS=() +if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ] && [ -n "${CAMEL_PACKAGE_TEST_VERSION:-}" ]; then + SNAPSHOT_PATTERN_ARGS=(-Djreleaser.project.snapshot.pattern=CAMEL_LAUNCHER_NEVER_MATCH_SNAPSHOT_PATTERN) +fi +echo "Preparing packages for channel '$CHANNEL' (packagers: $PACKAGERS)..." +mvn -B -ntp -f "$MODULE_DIR/pom.xml" \ + -Djreleaser.distributions=camel-cli,camel-cli-winget \ + -Djreleaser.packagers="$PACKAGERS" \ + "${SNAPSHOT_PATTERN_ARGS[@]}" \ + jreleaser:config jreleaser:prepare jreleaser:package \ + -Djreleaser.dry.run=true + +# Homebrew's own versioned-formula convention names the *file* "camel@X.Y.rb" but +# the pinned JReleaser plugin derives the output filename from formulaName's literal text +# (kebab-casing our "CamelAT420" class name to "camel-at-420.rb"), not from Homebrew's +# file-naming rule. The generated formula content already has the correct class name, so only +# the filename needs fixing up here. +if [ "$CHANNEL" = "lts" ]; then + brew_formula_dir="$MODULE_DIR/target/jreleaser/package/camel-cli/brew/Formula" + # `brew` is always in $PACKAGERS for this channel, so a missing output directory means + # JReleaser's layout changed and the rename below silently stopped happening. Fail rather + # than ship a formula under JReleaser's kebab-cased filename. + if [ ! -d "$brew_formula_dir" ]; then + echo "Error: no generated Homebrew formula directory at $brew_formula_dir." 1>&2 + exit 1 + fi + generated_file="" + for f in "$brew_formula_dir"/*.rb; do + [ -e "$f" ] || continue + if [ -n "$generated_file" ]; then + echo "Error: expected exactly one generated Homebrew formula file in $brew_formula_dir, found multiple." 1>&2 + exit 1 + fi + generated_file="$f" + done + if [ -z "$generated_file" ]; then + echo "Error: expected exactly one generated Homebrew formula file in $brew_formula_dir, found none." 1>&2 + exit 1 + fi + if [ "$generated_file" != "$brew_formula_dir/$BREW_FORMULA.rb" ]; then + mv -f "$generated_file" "$brew_formula_dir/$BREW_FORMULA.rb" + echo "Renamed generated Homebrew formula to Homebrew's versioned-formula file convention: $BREW_FORMULA.rb" + fi +fi diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl new file mode 100644 index 0000000000000..1aa5aee96077b --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl @@ -0,0 +1,73 @@ +# +# 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. +# + +# {{jreleaserCreationStamp}} +{{#brewRequireRelative}} +require_relative "{{.}}" +{{/brewRequireRelative}} + +class {{brewFormulaName}} < Formula + desc "{{projectDescription}}" + homepage "{{{projectLinkHomepage}}}" + url "{{{distributionUrl}}}"{{#brewDownloadStrategy}}, :using => {{.}}{{/brewDownloadStrategy}} + version "{{projectVersion}}" + sha256 "{{distributionChecksumSha256}}" + license "{{projectLicense}}" +{{#brewVersionedFormula}} + + keg_only :versioned_formula +{{/brewVersionedFormula}} +{{#brewHasLivecheck}} + + livecheck do + {{#brewLivecheck}} + {{.}} + {{/brewLivecheck}} + end +{{/brewHasLivecheck}} + + depends_on "openjdk" + + def install + libexec.install Dir["*"] + (bin/"{{distributionExecutableName}}").write_env_script libexec/"bin/camel.sh", + CAMEL_FALLBACK_JAVA: "#{formula_opt_bin("openjdk")}/java" + end + + def caveats + <<~EOS + Apache Camel CLI installs its own Homebrew OpenJDK dependency even if a + compatible Java 17+ is already present. The launcher selects a Java runtime in + this order: JAVACMD, JAVA_HOME/bin/java, the first java on PATH, then + CAMEL_FALLBACK_JAVA (which this formula points at the Homebrew OpenJDK). + + Run "camel version" to verify the install. +{{#brewVersionedFormula}} + + #{name} is keg-only: it was not symlinked into #{HOMEBREW_PREFIX} because + another version of this formula may also be installed. To use this version's + "camel" first in your PATH, run: + echo 'export PATH="#{opt_bin}:$PATH"' >> ~/.zshrc +{{/brewVersionedFormula}} + EOS + end + + test do + output = shell_output("#{bin}/{{distributionExecutableName}} --version") + assert_match "{{projectVersion}}", output + end +end diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl new file mode 100644 index 0000000000000..d377b36609cd8 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl @@ -0,0 +1,31 @@ +# +# 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. +# + +# {{jreleaserCreationStamp}} +$tools = Split-Path $MyInvocation.MyCommand.Definition +$package = Split-Path $tools +$app_home = Join-Path $package '{{distributionArtifactRootEntryName}}' +$app_exe = Join-Path $app_home 'bin/{{distributionExecutableWindows}}' + +Install-ChocolateyZipPackage ` + -PackageName '{{chocolateyPackageName}}' ` + -Url '{{{distributionUrl}}}' ` + -Checksum '{{distributionChecksumSha256}}' ` + -ChecksumType 'sha256' ` + -UnzipLocation $package + +Install-BinFile -Name '{{distributionExecutableName}}' -Path $app_exe diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyuninstall.ps1.tpl b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyuninstall.ps1.tpl new file mode 100644 index 0000000000000..526442f3a27d3 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyuninstall.ps1.tpl @@ -0,0 +1,24 @@ +# +# 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. +# + +# {{jreleaserCreationStamp}} +$tools = Split-Path $MyInvocation.MyCommand.Definition +$package = Split-Path $tools +$app_home = Join-Path $package '{{distributionArtifactRootEntryName}}' +$app_exe = Join-Path $app_home 'bin/{{distributionExecutableWindows}}' + +Uninstall-BinFile -Name '{{distributionExecutableName}}' -Path $app_exe diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl new file mode 100644 index 0000000000000..a43488daba914 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl @@ -0,0 +1,24 @@ +{ + "version": "{{projectVersion}}", + "description": "{{projectDescription}}", + "homepage": "{{{projectLinkHomepage}}}", + "license": "{{projectLicense}}", + "url": "{{{distributionUrl}}}", + "hash": "sha256:{{distributionChecksumSha256}}", + "extract_dir": "{{distributionArtifactRootEntryName}}", + "bin": "bin\\{{distributionExecutableWindows}}", + "suggest": { + "JDK": [ + "java/oraclejdk", + "java/openjdk" + ] + }, + "checkver": { + "url": "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/maven-metadata.xml", + "regex": "([\\d.]+)" + }, + "autoupdate": { + "url": "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/$version/camel-launcher-$version-bin.zip", + "extract_dir": "camel-launcher-$version" + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md new file mode 100644 index 0000000000000..fea5d6177f7fb --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md @@ -0,0 +1,30 @@ + + + + +Apache Camel CLI requires Java 17 or newer. If you do not already have a +compatible Java, "sdk install java" provides one and SDKMAN will export +JAVA_HOME for it; any other Java 17+ runtime works equally well. The bin/camel +launcher discovers Java via JAVACMD, JAVA_HOME, PATH, then CAMEL_FALLBACK_JAVA, +and prints a diagnostic listing these sources if none qualify. diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/installer.yaml.tpl b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/installer.yaml.tpl new file mode 100644 index 0000000000000..460494ceb6fd6 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/installer.yaml.tpl @@ -0,0 +1,93 @@ +# +# 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. +# + +# {{jreleaserCreationStamp}} +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json +# +# Overrides the default JReleaser java-binary/winget template (which declares a single +# Architecture: neutral entry) to declare per-architecture installers for x64 and arm64. +# The dedicated WinGet distribution zip ships both camel-x64.exe and camel-arm64.exe in +# bin/ (see src/main/assembly/winget-bin.xml), so each architecture entry selects the correct +# native exe via NestedInstallerFiles/RelativeFilePath. WinGet keeps the complete extracted +# archive; NestedInstallerFiles selects the file exposed through the portable camel.exe alias. + +PackageIdentifier: {{wingetPackageIdentifier}} +PackageVersion: {{wingetPackageVersion}} +ReleaseDate: {{wingetReleaseDate}} +Installers: + - Architecture: x64 + InstallerUrl: {{{distributionUrl}}} + InstallerSha256: {{distributionChecksumSha256}} + InstallerType: zip + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: {{distributionArtifactRootEntryName}}\bin\camel-x64.exe + PortableCommandAlias: camel + {{#wingetHasDependencies}} + Dependencies: + {{#wingetHasWindowsFeatures}} + WindowsFeatures: + {{#wingetWindowsFeatures}}- {{.}}{{/wingetWindowsFeatures}} + {{/wingetHasWindowsFeatures}} + {{#wingetHasWindowsLibraries}} + WindowsLibraries: + {{#wingetWindowsLibraries}}- {{.}}{{/wingetWindowsLibraries}} + {{/wingetHasWindowsLibraries}} + {{#wingetHasExternalDependencies}} + ExternalDependencies: + {{#wingetExternalDependencies}}- {{.}}{{/wingetExternalDependencies}} + {{/wingetHasExternalDependencies}} + {{#wingetHasPackageDependencies}} + PackageDependencies: + {{#wingetPackageDependencies}} + - PackageIdentifier: {{packageIdentifier}} + {{#minimumVersion}}MinimumVersion: {{.}}{{/minimumVersion}} + {{/wingetPackageDependencies}} + {{/wingetHasPackageDependencies}} + {{/wingetHasDependencies}} + - Architecture: arm64 + InstallerUrl: {{{distributionUrl}}} + InstallerSha256: {{distributionChecksumSha256}} + InstallerType: zip + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: {{distributionArtifactRootEntryName}}\bin\camel-arm64.exe + PortableCommandAlias: camel + {{#wingetHasDependencies}} + Dependencies: + {{#wingetHasWindowsFeatures}} + WindowsFeatures: + {{#wingetWindowsFeatures}}- {{.}}{{/wingetWindowsFeatures}} + {{/wingetHasWindowsFeatures}} + {{#wingetHasWindowsLibraries}} + WindowsLibraries: + {{#wingetWindowsLibraries}}- {{.}}{{/wingetWindowsLibraries}} + {{/wingetHasWindowsLibraries}} + {{#wingetHasExternalDependencies}} + ExternalDependencies: + {{#wingetExternalDependencies}}- {{.}}{{/wingetExternalDependencies}} + {{/wingetHasExternalDependencies}} + {{#wingetHasPackageDependencies}} + PackageDependencies: + {{#wingetPackageDependencies}} + - PackageIdentifier: {{packageIdentifier}} + {{#minimumVersion}}MinimumVersion: {{.}}{{/minimumVersion}} + {{/wingetPackageDependencies}} + {{/wingetHasPackageDependencies}} + {{/wingetHasDependencies}} +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/locale.yaml.tpl b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/locale.yaml.tpl new file mode 100644 index 0000000000000..fbaeba63edd92 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/locale.yaml.tpl @@ -0,0 +1,44 @@ +# +# 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. +# + +# {{jreleaserCreationStamp}} +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json + +PackageIdentifier: {{wingetPackageIdentifier}} +PackageVersion: {{wingetPackageVersion}} +PackageLocale: {{wingetPackageLocale}} +Publisher: {{wingetPackagePublisher}} +PublisherUrl: {{wingetPublisherUrl}} +PublisherSupportUrl: {{wingetPublisherSupportUrl}} +Author: {{wingetAuthor}} +PackageName: {{wingetPackageName}} +PackageUrl: {{wingetPackageUrl}} +License: {{projectLicense}} +LicenseUrl: {{projectLinkLicense}} +Copyright: {{projectCopyright}} +ShortDescription: {{projectDescription}} +Description: {{projectLongDescription}} +Moniker: {{wingetMoniker}} +{{#wingetHasTags}} +Tags: + {{#wingetTags}} + - {{.}} + {{/wingetTags}} +{{/wingetHasTags}} +ReleaseNotesUrl: {{releaseNotesUrl}} +ManifestType: {{wingetManifestType}} +ManifestVersion: 1.12.0 diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/version.yaml.tpl b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/version.yaml.tpl new file mode 100644 index 0000000000000..f89e5422bc205 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/version.yaml.tpl @@ -0,0 +1,25 @@ +# +# 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. +# + +# {{jreleaserCreationStamp}} +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json + +PackageIdentifier: {{wingetPackageIdentifier}} +PackageVersion: {{wingetPackageVersion}} +DefaultLocale: {{wingetDefaultLocale}} +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java index 664c9d7673cde..5bc119b6e2284 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/java/WebsiteManifestGenerator.java @@ -15,6 +15,9 @@ * limitations under the License. */ +// Intentionally in the default package so camel-package.sh can run this JDK-only source-file +// tool directly by path without compiling or loading a package-qualified launcher class. + import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml new file mode 100644 index 0000000000000..f4224767e06a0 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml @@ -0,0 +1,20 @@ +# +# 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. +# + +supported: + - line: "4.22" + supportEnds: "2027-09-30" diff --git a/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml b/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml index 61fd0d4fab820..e9af7d4fefc34 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml +++ b/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml @@ -17,6 +17,13 @@ limitations under the License. --> + @@ -25,59 +32,7 @@ zip tar.gz - - - bin - - org.jolokia:jolokia-agent-jvm:jar:javaagent - - false - - - - - ${project.basedir} - - - README* - LICENSE* - NOTICE* - - - - ${project.build.directory} - bin - - *.jar - - - original-*.jar - - - - ${project.basedir}/src/main/resources/bin - bin - - camel.sh - camel.bat - - 0755 - - - ${project.basedir}/src/examples - examples - - *.* - - - - ${project.build.directory} - bin - - camel-x64.exe - camel-arm64.exe - - 0755 - - + + src/main/assembly/launcher-content.xml + diff --git a/dsl/camel-jbang/camel-launcher/src/main/assembly/launcher-content.xml b/dsl/camel-jbang/camel-launcher/src/main/assembly/launcher-content.xml new file mode 100644 index 0000000000000..f648f2f9d0ea0 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/main/assembly/launcher-content.xml @@ -0,0 +1,78 @@ + + + + + + + bin + + org.jolokia:jolokia-agent-jvm:jar:javaagent + + false + + + + + ${project.basedir} + + + README* + LICENSE* + NOTICE* + + + + ${project.build.directory} + bin + + *.jar + + + original-*.jar + + + + ${project.basedir}/src/main/resources/bin + bin + + camel.sh + camel.bat + + 0755 + + + ${project.basedir}/src/examples + examples + + *.* + + + + diff --git a/dsl/camel-jbang/camel-launcher/src/main/assembly/winget-bin.xml b/dsl/camel-jbang/camel-launcher/src/main/assembly/winget-bin.xml new file mode 100644 index 0000000000000..fb4e1f6e2b5f5 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/main/assembly/winget-bin.xml @@ -0,0 +1,49 @@ + + + + + winget-bin + + zip + + + src/main/assembly/launcher-content.xml + + + + ${project.build.directory} + bin + + camel-x64.exe + camel-arm64.exe + + 0755 + + + diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherNativeExeIT.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherNativeExeIT.java index bba13ac01a422..6536f7d829bf2 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherNativeExeIT.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherNativeExeIT.java @@ -28,10 +28,11 @@ import org.junit.jupiter.api.condition.EnabledIfSystemProperty; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Release gate for the native exe files (x64 + arm64) packaged into the launcher distribution. Runs during + * Release gate for the native exe files (x64 + arm64) packaged only into the WinGet launcher distribution. Runs during * {@code verify} when {@code -Dcamel.exe.build=true} is set. Cross-compiled from any host OS via clang/llvm-mingw. */ @EnabledIfSystemProperty(named = "camel.exe.build", matches = "true") @@ -44,19 +45,19 @@ class CamelLauncherNativeExeIT { @Test void stagedCamelExeX64Exists() { assertTrue(Files.isRegularFile(STAGED_X64), - "target/camel-x64.exe must be present before packaging the launcher distribution"); + "target/camel-x64.exe must be present before packaging the WinGet launcher distribution"); } @Test void stagedCamelExeArm64Exists() { assertTrue(Files.isRegularFile(STAGED_ARM64), - "target/camel-arm64.exe must be present before packaging the launcher distribution"); + "target/camel-arm64.exe must be present before packaging the WinGet launcher distribution"); } @Test - void binArchiveIncludesBothExeFiles() throws IOException { - Path zip = findBinZip(); - assertNotNull(zip, "camel-launcher-*-bin.zip must be produced by the assembly plugin"); + void wingetArchiveIncludesBothExeFiles() throws IOException { + Path zip = findBinZip(true); + assertNotNull(zip, "camel-launcher-*-winget-bin.zip must be produced by the assembly plugin"); try (ZipFile archive = new ZipFile(zip.toFile())) { ZipEntry x64 = archive.stream() @@ -75,10 +76,26 @@ void binArchiveIncludesBothExeFiles() throws IOException { } } - private static Path findBinZip() throws IOException { + @Test + void publicArchiveExcludesNativeExeFiles() throws IOException { + Path zip = findBinZip(false); + assertNotNull(zip, "camel-launcher-*-bin.zip must be produced by the assembly plugin"); + + try (ZipFile archive = new ZipFile(zip.toFile())) { + assertNull(findEntry(archive, "/bin/camel-x64.exe"), "public ZIP must not include camel-x64.exe"); + assertNull(findEntry(archive, "/bin/camel-arm64.exe"), "public ZIP must not include camel-arm64.exe"); + } + } + + private static ZipEntry findEntry(ZipFile archive, String suffix) { + return archive.stream().filter(e -> e.getName().endsWith(suffix)).findFirst().orElse(null); + } + + private static Path findBinZip(boolean winget) throws IOException { try (Stream paths = Files.list(TARGET)) { return paths .filter(p -> p.getFileName().toString().matches("camel-launcher-.*-bin\\.zip")) + .filter(p -> p.getFileName().toString().contains("-winget-bin.zip") == winget) .findFirst() .orElse(null); } diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java new file mode 100644 index 0000000000000..f0a55b681a708 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java @@ -0,0 +1,863 @@ +/* + * 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.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.yaml.snakeyaml.Yaml; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the channel -> packaging-plan mapping, LTS validation, and website-staging logic in camel-package.sh. + */ +class PackagePlanTest { + + static final Path MODULE_DIR = Paths.get("").toAbsolutePath(); + static final String TEST_VERSION = "9.9.9"; + static final Path PACKAGE_DIR = MODULE_DIR.resolve("target/jreleaser/package"); + + // Synthetic LTS allowlist (see supported-lts-test-fixture.yml), decoupled from the real + // supported-lts.yml so LTS-expiry assertions never expire on their own wall-clock date. + static final Path SUPPORTED_LTS_FIXTURE = MODULE_DIR.resolve("src/test/resources/supported-lts-test-fixture.yml"); + static final String LTS_LINE_FUTURE = "9.9"; + static final String LTS_LINE_EXPIRED = "1.0"; + + static final class Result { + int exit; + String stdout; + String stderr; + } + + Path writeReleaseFixture(String suffix, String content) throws IOException { + Path target = MODULE_DIR.resolve("target"); + Files.createDirectories(target); + Path file = target.resolve("camel-launcher-" + TEST_VERSION + suffix); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + Path writeWingetFixture(String content) throws IOException { + return writeReleaseFixture("-winget-bin.zip", content); + } + + Path websiteDir() { + return MODULE_DIR.resolve("target/jreleaser/website"); + } + + Map supportedLtsFixtureEnv() { + Map env = new LinkedHashMap<>(); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_SUPPORTED_LTS", SUPPORTED_LTS_FIXTURE.toString()); + return env; + } + + @AfterEach + void cleanupFixtures() throws IOException { + Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-bin.tar.gz")); + Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-bin.zip")); + Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-winget-bin.zip")); + deleteRecursively(websiteDir()); + deleteRecursively(PACKAGE_DIR); + } + + static void deleteRecursively(Path dir) throws IOException { + if (!Files.exists(dir)) { + return; + } + try (var stream = Files.walk(dir)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + static String sha256Hex(Path file) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(Files.readAllBytes(file))); + } + + /** + * The production allowlist is the release gate for {@code --channel lts}: every line it advertises must still be + * accepted by the wrapper. This starts failing once the last entry's {@code supportEnds} date passes, which is the + * intended signal to add the next LTS line. Unlike a hardcoded date comparison, it exercises the real parsing and + * expiry logic in camel-package.sh against the real file. + */ + @Test + void everyProductionLtsLineIsStillAccepted() throws Exception { + List lines = productionLtsLines(); + assertFalse(lines.isEmpty(), "supported-lts.yml must advertise at least one LTS line"); + + for (String line : lines) { + Result r = run("prepare", "--channel", "lts", "--lts-line", line, "--print-plan"); + + assertEquals(0, r.exit, + "supported-lts.yml advertises LTS line " + line + " but the wrapper rejected it: " + r.stderr); + assertTrue(r.stdout.contains("BREW_FORMULA=camel@" + line), r.stdout); + } + } + + @SuppressWarnings("unchecked") + private List productionLtsLines() throws IOException { + try (var in = Files.newInputStream(MODULE_DIR.resolve("src/jreleaser/supported-lts.yml"))) { + Map root = new Yaml().load(in); + List> supported = (List>) root.get("supported"); + return supported.stream().map(entry -> String.valueOf(entry.get("line"))).toList(); + } + } + + /** + * Chocolatey and Scoop install from the public archive, which no longer carries the native executables, so both + * must enter through the architecture-neutral batch launcher. Rendering these Mustache templates needs JReleaser, + * so the entry point is asserted on the template source. + */ + @Test + void archNeutralPackagersEnterThroughTheBatchLauncher() throws Exception { + String chocolatey = Files.readString(MODULE_DIR.resolve( + "src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl"), + StandardCharsets.UTF_8); + String scoop = Files.readString( + MODULE_DIR.resolve("src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl"), + StandardCharsets.UTF_8); + + for (String template : List.of(chocolatey, scoop)) { + assertTrue(template.contains("{{distributionExecutableWindows}}"), + "the Windows entry point must resolve to camel.bat via JReleaser: " + template); + assertFalse(template.contains("camel-x64.exe"), template); + assertFalse(template.contains("camel-arm64.exe"), template); + } + } + + /** + * The assembly descriptors decide what each archive carries. Asserted structurally rather than by substring so + * reformatting the XML cannot break the test while a real content change slips through. The resulting archives are + * checked for real by {@link CamelLauncherNativeExeIT}, which only runs with -Dcamel.exe.build=true. + */ + @Test + void nativeExecutablesAreConfinedToWingetArchive() throws Exception { + Path publicAssembly = MODULE_DIR.resolve("src/main/assembly/bin.xml"); + Path wingetAssembly = MODULE_DIR.resolve("src/main/assembly/winget-bin.xml"); + + List publicIncludes = effectiveIncludes(publicAssembly); + assertFalse(publicIncludes.contains("camel-x64.exe"), + "the public ZIP/TAR assembly must not expose WinGet-only native executables"); + assertFalse(publicIncludes.contains("camel-arm64.exe"), + "the public ZIP/TAR assembly must not expose WinGet-only native executables"); + + List wingetIncludes = effectiveIncludes(wingetAssembly); + assertTrue(wingetIncludes.contains("camel-x64.exe"), wingetIncludes.toString()); + assertTrue(wingetIncludes.contains("camel-arm64.exe"), wingetIncludes.toString()); + assertTrue(wingetIncludes.contains("*.jar"), "the WinGet archive must retain the launcher JAR beside camel.bat"); + assertTrue(wingetIncludes.contains("camel.bat"), + "the native bootstrap delegates to camel.bat beside its resolved target"); + assertEquals(List.of("zip"), elementTexts(parseXml(wingetAssembly), "format"), + "the WinGet payload is a ZIP only; WinGet cannot consume a tar.gz"); + } + + /** + * Both archives must deliver the same CLI, so their common content is declared once in a shared assembly component. + * Re-inlining it into either descriptor would let the two drift apart silently, which is precisely what the + * surrounding assertions could no longer detect. + */ + @Test + void bothArchivesShareOneContentDefinition() throws Exception { + List publicComponents = elementTexts( + parseXml(MODULE_DIR.resolve("src/main/assembly/bin.xml")), "componentDescriptor"); + List wingetComponents = elementTexts( + parseXml(MODULE_DIR.resolve("src/main/assembly/winget-bin.xml")), "componentDescriptor"); + + assertEquals(List.of("src/main/assembly/launcher-content.xml"), publicComponents); + assertEquals(publicComponents, wingetComponents, + "the public and WinGet archives must draw their common content from the same component"); + + // The WinGet archive's only declared difference is the native bootstraps. + assertEquals(List.of("camel-x64.exe", "camel-arm64.exe"), + elementTexts(parseXml(MODULE_DIR.resolve("src/main/assembly/winget-bin.xml")), "include"), + "winget-bin.xml must add the native executables and nothing else"); + } + + /** + * The WinGet payload is deliberately never installed or deployed to a Maven repository, so it must be produced by a + * non-attached execution that only the native-executable profile enables. CI additionally proves the exclusion by + * deploying to a throwaway repository and failing if the ZIP appears. + */ + @Test + void wingetArchiveIsBuiltOnlyByTheNativeProfileAndNeverAttached() throws Exception { + Document pom = parseXml(MODULE_DIR.resolve("pom.xml")); + + Element profile = profileById(pom, "include-camel-exe"); + assertNotNull(profile, "the native executable profile must exist"); + Element execution = executionById(profile, "assemble-winget-bin"); + assertNotNull(execution, "the native executable profile must create the WinGet archive"); + assertEquals(List.of("src/main/assembly/winget-bin.xml"), elementTexts(execution, "descriptor")); + assertEquals(List.of("false"), elementTexts(execution, "attach"), + "the WinGet payload must remain a local release file, not an attached Maven artifact"); + + Element build = firstChild(pom.getDocumentElement(), "build"); + assertNotNull(build, "the module must declare a build section"); + assertFalse(elementTexts(build, "descriptor").contains("src/main/assembly/winget-bin.xml"), + "ordinary builds must not create an incomplete WinGet archive"); + } + + @Test + @SuppressWarnings("unchecked") + void wingetUsesDedicatedJreleaserDistribution() throws Exception { + Map distributions; + try (var in = Files.newInputStream(MODULE_DIR.resolve("jreleaser.yml"))) { + Map config = new Yaml().load(in); + distributions = (Map) config.get("distributions"); + } + + Map publicDistribution = (Map) distributions.get("camel-cli"); + Map wingetDistribution = (Map) distributions.get("camel-cli-winget"); + assertNotNull(wingetDistribution, "a dedicated WinGet distribution must exist: " + distributions.keySet()); + assertFalse(publicDistribution.containsKey("winget"), + "the public distribution must not generate the WinGet package"); + + List> artifacts = (List>) wingetDistribution.get("artifacts"); + assertEquals(1, artifacts.size(), artifacts.toString()); + assertTrue(String.valueOf(artifacts.get(0).get("path")).endsWith("-winget-bin.zip"), artifacts.toString()); + + String downloadUrl = String.valueOf(((Map) wingetDistribution.get("winget")).get("downloadUrl")); + assertTrue(downloadUrl.startsWith("https://archive.apache.org/dist/camel/apache-camel/"), + "the WinGet payload is served from the Apache archive, not Maven Central: " + downloadUrl); + } + + /** + * install.ps1 downloads the public archive, which no longer carries the native executables, so it must not select + * one by architecture. The installer's behaviour is covered by {@code WebsiteInstallTest}, but those tests are + * {@code @EnabledOnOs(WINDOWS)}; this keeps the invariant guarded on every platform. + */ + @Test + void websiteWindowsInstallerUsesArchitectureNeutralBatchLauncher() throws Exception { + String installer = Files.readString(MODULE_DIR.resolve("src/install/install.ps1"), StandardCharsets.UTF_8); + + assertFalse(installer.contains("camel-x64.exe"), installer); + assertFalse(installer.contains("camel-arm64.exe"), installer); + assertFalse(installer.contains("PROCESSOR_ARCHITECTURE"), + "the installer must not branch on host architecture: " + installer); + } + + /** + * Maven Central publishes only a SHA-1 sidecar for these artifacts, so pointing Scoop's autoupdate at one would + * silently downgrade future versions from the SHA-256 pinned here to SHA-1. Omitting the autoupdate hash entirely + * makes Scoop download the artifact and compute SHA-256 itself, keeping one algorithm across every version. + */ + @Test + @SuppressWarnings("unchecked") + void scoopAutoupdateComputesSha256RatherThanReusingTheSha1Sidecar() throws Exception { + // Every Mustache placeholder in this template sits inside a quoted JSON string, so it parses as JSON as-is. + Map manifest; + try (var in = Files.newInputStream( + MODULE_DIR.resolve("src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl"))) { + manifest = new Yaml().load(in); + } + + assertTrue(String.valueOf(manifest.get("hash")).startsWith("sha256:"), + "the pinned hash must be SHA-256: " + manifest.get("hash")); + Map autoupdate = (Map) manifest.get("autoupdate"); + assertNotNull(autoupdate, "Scoop autoupdate must stay configured: " + manifest); + assertFalse(autoupdate.containsKey("hash"), + "declaring an autoupdate hash source would pin future versions to Maven Central's SHA-1 sidecar: " + + autoupdate); + } + + /** + * WinGet needs a real per-architecture executable, so the JReleaser default (a single {@code neutral} entry) is + * overridden. Both entries describe the same approved ZIP and differ only by which nested exe they expose. + */ + @Test + void wingetInstallerManifestDeclaresBothArchitecturesFromOneArchive() throws Exception { + String winget = Files.readString( + MODULE_DIR.resolve("src/jreleaser/distributions/camel-cli/winget/installer.yaml.tpl"), + StandardCharsets.UTF_8); + + assertTrue(winget.contains("Architecture: x64"), winget); + assertTrue(winget.contains("Architecture: arm64"), winget); + assertTrue(winget.contains("bin\\camel-x64.exe"), winget); + assertTrue(winget.contains("bin\\camel-arm64.exe"), winget); + assertEquals(2, countOccurrences(winget, "InstallerSha256: {{distributionChecksumSha256}}"), + "both architecture entries must use the checksum of the same approved ZIP"); + + // The editor schema hint and the declared manifest version are the same contract; drifting apart means the + // manifest is validated locally against a version WinGet is not being told to expect. + Matcher declared = Pattern.compile("ManifestVersion: (\\S+)").matcher(winget); + assertTrue(declared.find(), winget); + assertTrue(winget.contains("winget-manifest.installer." + declared.group(1) + ".schema.json"), + "the yaml-language-server schema must match ManifestVersion " + declared.group(1)); + } + + /** + * WinGet validates the installer, locale, and version manifests as one set, so JReleaser must not fall back to + * embedded templates that declare an older schema version than the custom installer template. + */ + @Test + void wingetManifestTemplatesUseTheSameCurrentSchemaVersion() throws Exception { + Path wingetTemplates = MODULE_DIR.resolve("src/jreleaser/distributions/camel-cli/winget"); + + for (String templateName : List.of("installer.yaml.tpl", "locale.yaml.tpl", "version.yaml.tpl")) { + Path template = wingetTemplates.resolve(templateName); + assertTrue(Files.isRegularFile(template), "WinGet manifest template must be overridden: " + template); + + String manifest = Files.readString(template, StandardCharsets.UTF_8); + Matcher declared = Pattern.compile("ManifestVersion: (\\S+)").matcher(manifest); + assertTrue(declared.find(), manifest); + assertEquals("1.12.0", declared.group(1), "unexpected WinGet schema version in " + template); + assertTrue(manifest.contains("." + declared.group(1) + ".schema.json"), + "the yaml-language-server schema must match ManifestVersion in " + template); + } + } + + private static int countOccurrences(String haystack, String needle) { + return haystack.split(Pattern.quote(needle), -1).length - 1; + } + + private static final Path SCRIPT = Paths.get("src/jreleaser/bin/camel-package.sh"); + + private Result run(String... args) throws Exception { + return run(Map.of(), args); + } + + private Result run(Map extraEnv, String... args) throws Exception { + List cmd = new ArrayList<>(); + cmd.add("bash"); + cmd.add(SCRIPT.toString()); + Collections.addAll(cmd, args); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.environment().putAll(extraEnv); + Process p = pb.start(); + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(p.waitFor(60, TimeUnit.SECONDS), "wrapper did not exit in time"); + Result r = new Result(); + r.exit = p.exitValue(); + r.stdout = out; + r.stderr = err; + return r; + } + + private Map testModeEnvWithMvnStub(Path tmp, Path recordFile) throws IOException { + Path stubDir = tmp.resolve("stub-bin"); + Files.createDirectories(stubDir); + Path mvnStub = stubDir.resolve("mvn"); + Files.writeString(mvnStub, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"" + recordFile + "\"\nexit 0\n", + StandardCharsets.UTF_8); + assertTrue(mvnStub.toFile().setExecutable(true)); + + Path winget = writeWingetFixture("fixture-winget"); + Map env = new LinkedHashMap<>(); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); + env.put("CAMEL_PACKAGE_TEST_WINGET_REMOTE", winget.toString()); + env.put("PATH", stubDir + File.pathSeparator + System.getenv("PATH")); + return env; + } + + private Map envWithMvnStubProducingFormula(Path tmp, Path recordFile, String formulaName) + throws IOException { + Path stubDir = tmp.resolve("stub-bin"); + Files.createDirectories(stubDir); + Path formulaDir = PACKAGE_DIR.resolve("camel-cli/brew/Formula"); + Path mvnStub = stubDir.resolve("mvn"); + Files.writeString(mvnStub, + "#!/bin/sh\n" + + "case \"$*\" in\n" + + " *evaluate*) printf '%s\\n' '" + TEST_VERSION + "' ; exit 0 ;;\n" + + "esac\n" + + "printf '%s\\n' \"$*\" >> \"" + recordFile + "\"\n" + + "mkdir -p \"" + formulaDir + "\"\n" + + "cat > \"" + formulaDir.resolve(formulaName) + "\" <<'EOF'\n" + + " url \"https://example.invalid/original.zip\"\n" + + " version \"" + TEST_VERSION + "\"\n" + + " sha256 \"original\"\n" + + " assert_match \"" + TEST_VERSION + "\", output\n" + + "EOF\n" + + "exit 0\n", + StandardCharsets.UTF_8); + assertTrue(mvnStub.toFile().setExecutable(true)); + + Path winget = writeWingetFixture("fixture-winget"); + Map env = new LinkedHashMap<>(); + env.put("CAMEL_PACKAGE_TEST_WINGET_REMOTE", winget.toString()); + env.put("PATH", stubDir + File.pathSeparator + System.getenv("PATH")); + return env; + } + + private Map productionStyleEnvWithMvnStub(Path tmp, Path recordFile) throws IOException { + Path stubDir = tmp.resolve("stub-bin"); + Files.createDirectories(stubDir); + Path mvnStub = stubDir.resolve("mvn"); + Files.writeString(mvnStub, + "#!/bin/sh\n" + + "case \"$*\" in\n" + + " *evaluate*) printf '%s\\n' '" + TEST_VERSION + "' ; exit 0 ;;\n" + + "esac\n" + + "printf '<%s>\\n' \"$@\" > \"" + recordFile + "\"\n" + + "exit 0\n", + StandardCharsets.UTF_8); + assertTrue(mvnStub.toFile().setExecutable(true)); + + Path winget = writeWingetFixture("fixture-winget"); + Path curlRecord = tmp.resolve("archive-curl.txt"); + Path curlStub = stubDir.resolve("curl"); + Files.writeString(curlStub, + "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> \"" + curlRecord + "\"\n" + + "output=''\n" + + "previous=''\n" + + "for argument in \"$@\"; do\n" + + " if [ \"$previous\" = '-o' ]; then output=$argument; fi\n" + + " previous=$argument\n" + + "done\n" + + "[ -n \"$output\" ] || exit 98\n" + + "cp \"" + winget + "\" \"$output\"\n", + StandardCharsets.UTF_8); + assertTrue(curlStub.toFile().setExecutable(true)); + + Map env = new LinkedHashMap<>(); + env.put("PATH", stubDir + File.pathSeparator + System.getenv("PATH")); + return env; + } + + @Test + void stableSelectsAllFivePackagersWithSdkmanDefault() throws Exception { + Result r = run("prepare", "--channel", "stable", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("CHANNEL=stable"), r.stdout); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,scoop,chocolatey"), r.stdout); + assertTrue(r.stdout.contains("BREW_FORMULA=camel\n") || r.stdout.contains("BREW_FORMULA=camel\r\n") + || r.stdout.trim().endsWith("BREW_FORMULA=camel"), r.stdout); + assertTrue(r.stdout.contains("BREW_CLASS=Camel"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_CANDIDATE=camel"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=true"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_VERSION_MANIFEST=true"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_LATEST=true"), r.stdout); + assertFalse(r.stdout.contains("BREW_LTS_FORMULA="), "stable without --lts-line has no LTS formula"); + } + + @Test + void stableWithLtsAddsVersionedBrewFormula() throws Exception { + Result r = run(supportedLtsFixtureEnv(), "prepare", "--channel", "stable", "--lts-line", LTS_LINE_FUTURE, + "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("CHANNEL=stable"), r.stdout); + assertTrue(r.stdout.contains("LTS_LINE=" + LTS_LINE_FUTURE), r.stdout); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,scoop,chocolatey"), r.stdout); + assertTrue(r.stdout.contains("BREW_LTS_FORMULA=camel@" + LTS_LINE_FUTURE), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=true"), r.stdout); + } + + @Test + void ltsSelectsFourPackagersExcludingScoopWithSdkmanNotDefault() throws Exception { + // Deliberately reads the real production supported-lts.yml (no CAMEL_PACKAGE_TEST_SUPPORTED_LTS + // override) so an accidental deletion/typo of the real "4.22" entry is caught here. + Result r = run("prepare", "--channel", "lts", "--lts-line", "4.22", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("CHANNEL=lts"), r.stdout); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,chocolatey"), r.stdout); + assertFalse(r.stdout.contains("scoop"), "LTS maintenance excludes Scoop: " + r.stdout); + assertTrue(r.stdout.contains("BREW_FORMULA=camel@4.22"), + "LTS produces a versioned brew formula: " + r.stdout); + assertTrue(r.stdout.contains("BREW_CLASS=CamelAT422"), + "LTS formulaName must be a valid Ruby class name, pre-converted to Homebrew's AT convention: " + + r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=false"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_VERSION_MANIFEST=true"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_LATEST=false"), r.stdout); + } + + @Test + void ltsChannelRequiresLtsLine() throws Exception { + Result r = run("prepare", "--channel", "lts", "--print-plan"); + + assertEquals(2, r.exit, "missing --lts-line for lts channel must be a usage error"); + assertTrue(r.stderr.toLowerCase().contains("lts-line"), r.stderr); + } + + @Test + void rejectsUnsupportedLtsLine() throws Exception { + Result r = run("prepare", "--channel", "lts", "--lts-line", "3.14", "--print-plan"); + + assertEquals(2, r.exit); + assertTrue(r.stderr.toLowerCase().contains("not a supported lts line") + || r.stderr.contains("3.14"), r.stderr); + } + + @Test + void rejectsExpiredLtsLine() throws Exception { + Result r = run(supportedLtsFixtureEnv(), "prepare", "--channel", "lts", "--lts-line", LTS_LINE_EXPIRED, + "--print-plan"); + + assertEquals(2, r.exit); + assertTrue(r.stderr.toLowerCase().contains("support ended") || r.stderr.contains(LTS_LINE_EXPIRED), + r.stderr); + } + + @Test + void rejectsMalformedSupportedLtsMetadata() throws Exception { + Map env = new LinkedHashMap<>(); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_SUPPORTED_LTS", MODULE_DIR.resolve("src/test/resources/bad.yaml").toString()); + + Result r = run(env, "prepare", "--channel", "lts", "--lts-line", LTS_LINE_FUTURE, "--print-plan"); + + assertEquals(1, r.exit); + assertTrue(r.stderr.toLowerCase().contains("malformed"), r.stderr); + } + + @Test + void rejectsUnknownChannel() throws Exception { + Result r = run("prepare", "--channel", "nightly", "--print-plan"); + + assertEquals(2, r.exit); + assertTrue(r.stderr.toLowerCase().contains("channel"), r.stderr); + } + + @Test + void rejectsUnknownSubcommand() throws Exception { + Result r = run("frobnicate", "--channel", "stable", "--print-plan"); + + assertEquals(2, r.exit); + } + + @Test + void stableStagesInstallersAndWritesWebsiteManifests(@TempDir Path tmp) throws Exception { + Path tar = writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + Path zip = writeReleaseFixture("-bin.zip", "fixture-zip"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertEquals(0, r.exit, r.stderr); + Path installSh = websiteDir().resolve("install.sh"); + Path installPs1 = websiteDir().resolve("install.ps1"); + assertArrayEquals(Files.readAllBytes(MODULE_DIR.resolve("src/install/install.sh")), + Files.readAllBytes(installSh)); + assertArrayEquals(Files.readAllBytes(MODULE_DIR.resolve("src/install/install.ps1")), + Files.readAllBytes(installPs1)); + + Path versionManifest = websiteDir().resolve("camel-cli/releases/" + TEST_VERSION + ".properties"); + Path latestManifest = websiteDir().resolve("camel-cli/releases/latest.properties"); + assertTrue(Files.exists(versionManifest)); + assertTrue(Files.exists(latestManifest)); + String expected = "format=1\nversion=" + TEST_VERSION + "\ntar_sha256=" + sha256Hex(tar) + "\nzip_sha256=" + + sha256Hex(zip) + "\n"; + assertEquals(expected, Files.readString(versionManifest, StandardCharsets.UTF_8)); + assertArrayEquals(Files.readAllBytes(versionManifest), Files.readAllBytes(latestManifest)); + + assertTrue(Files.exists(recordFile), + "the JReleaser (stubbed mvn) step must be reached once staging succeeds"); + String recorded = Files.readString(recordFile, StandardCharsets.UTF_8); + assertTrue(recorded.contains("jreleaser:config"), recorded); + assertTrue(recorded.contains("jreleaser:package"), recorded); + assertTrue(recorded.contains("-Djreleaser.packagers=brew,sdkman,winget,scoop,chocolatey"), recorded); + assertTrue(recorded.contains("-Djreleaser.project.snapshot.pattern="), recorded); + } + + @Test + void productionPrepareDoesNotPassAnEmptyMavenArgument(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + writeReleaseFixture("-bin.zip", "fixture-zip"); + Path recordFile = tmp.resolve("mvn-args.txt"); + + Result r = run(productionStyleEnvWithMvnStub(tmp, recordFile), "prepare", "--channel", "stable"); + + assertEquals(0, r.exit, r.stderr); + String recorded = Files.readString(recordFile, StandardCharsets.UTF_8); + assertFalse(recorded.contains("<>"), + "production prepare must omit the unused snapshot-pattern argument:\n" + recorded); + assertTrue(recorded.contains("<-Djreleaser.distributions=camel-cli,camel-cli-winget>"), recorded); + } + + @Test + void productionPrepareVerifiesTheArchivedWingetPayload(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + writeReleaseFixture("-bin.zip", "fixture-zip"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + + Result r = run(productionStyleEnvWithMvnStub(tmp, recordFile), "prepare", "--channel", "stable"); + + assertEquals(0, r.exit, r.stderr); + String curlCalls = Files.readString(tmp.resolve("archive-curl.txt"), StandardCharsets.UTF_8); + assertTrue(curlCalls.contains("https://archive.apache.org/dist/camel/apache-camel/" + TEST_VERSION + + "/camel-launcher-" + TEST_VERSION + "-winget-bin.zip"), + curlCalls); + assertTrue(Files.exists(recordFile), "JReleaser must run after the byte comparison succeeds"); + } + + @Test + void prepareRejectsAnArchivedWingetPayloadWithDifferentBytes(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + writeReleaseFixture("-bin.zip", "fixture-zip"); + Path remote = tmp.resolve("remote-winget.zip"); + Files.writeString(remote, "different-remote-winget", StandardCharsets.UTF_8); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + writeWingetFixture("local-winget"); + env.put("CAMEL_PACKAGE_TEST_WINGET_REMOTE", remote.toString()); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertNotEquals(0, r.exit); + assertTrue(r.stderr.contains("does not match the archived WinGet payload"), r.stderr); + assertFalse(Files.exists(recordFile), "JReleaser must not run after a byte mismatch"); + } + + @Test + void wingetRemoteOverrideRequiresExplicitTestMode(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + writeReleaseFixture("-bin.zip", "fixture-zip"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = new LinkedHashMap<>(productionStyleEnvWithMvnStub(tmp, recordFile)); + env.put("CAMEL_PACKAGE_TEST_WINGET_REMOTE", writeWingetFixture("fixture-winget").toString()); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertEquals(2, r.exit); + assertTrue(r.stderr.contains("CAMEL_PACKAGE_TEST_WINGET_REMOTE requires CAMEL_PACKAGE_TEST_MODE=true"), + r.stderr); + assertFalse(Files.exists(recordFile)); + } + + @Test + void missingWingetPayloadFailsBeforeJReleaser(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + writeReleaseFixture("-bin.zip", "fixture-zip"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + Files.delete(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-winget-bin.zip")); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertNotEquals(0, r.exit); + assertTrue(r.stderr.contains("WinGet release ZIP not found"), r.stderr); + assertFalse(Files.exists(recordFile)); + } + + @Test + void ltsStagesInstallersButDoesNotWriteLatest(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar-lts"); + writeReleaseFixture("-bin.zip", "fixture-zip-lts"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + // The stub must emit a Homebrew formula the way a real JReleaser run does: the LTS path treats a missing + // formula directory as a packaging failure. + Map env = envWithMvnStubProducingFormula(tmp, recordFile, "camel-at-99.rb"); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); + env.putAll(supportedLtsFixtureEnv()); + + Result r = run(env, "prepare", "--channel", "lts", "--lts-line", LTS_LINE_FUTURE); + + assertEquals(0, r.exit, r.stderr); + assertTrue(Files.exists(websiteDir().resolve("camel-cli/releases/" + TEST_VERSION + ".properties"))); + assertFalse(Files.exists(websiteDir().resolve("camel-cli/releases/latest.properties")), + "LTS prepare must not create or modify latest.properties"); + assertTrue(Files.exists(recordFile)); + } + + @Test + void ltsPrepareRenamesGeneratedHomebrewFormula(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar-lts"); + writeReleaseFixture("-bin.zip", "fixture-zip-lts"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = envWithMvnStubProducingFormula(tmp, recordFile, "camel-at-99.rb"); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); + env.putAll(supportedLtsFixtureEnv()); + + Result r = run(env, "prepare", "--channel", "lts", "--lts-line", LTS_LINE_FUTURE); + + assertEquals(0, r.exit, r.stderr); + Path formulaDir = PACKAGE_DIR.resolve("camel-cli/brew/Formula"); + assertFalse(Files.exists(formulaDir.resolve("camel-at-99.rb")), + "JReleaser's generated LTS filename must not be left behind"); + assertTrue(Files.exists(formulaDir.resolve("camel@" + LTS_LINE_FUTURE + ".rb")), + "LTS Homebrew formula must use Homebrew's versioned-formula filename"); + } + + /** + * The generated formula is the release artifact: nothing after JReleaser may rewrite it. Guards against + * reintroducing the test-mode patching that used to swap in a published version's url/sha256. + */ + @Test + void prepareLeavesTheGeneratedHomebrewFormulaUntouched(@TempDir Path tmp) throws Exception { + writeReleaseFixture("-bin.tar.gz", "fixture-tar"); + writeReleaseFixture("-bin.zip", "fixture-zip"); + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = envWithMvnStubProducingFormula(tmp, recordFile, "camel.rb"); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertEquals(0, r.exit, r.stderr); + String formula = Files.readString(PACKAGE_DIR.resolve("camel-cli/brew/Formula/camel.rb"), StandardCharsets.UTF_8); + assertTrue(formula.contains("url \"https://example.invalid/original.zip\""), + "the url JReleaser generated must survive verbatim: " + formula); + assertTrue(formula.contains("sha256 \"original\""), + "the checksum JReleaser generated must survive verbatim: " + formula); + assertTrue(formula.contains("version \"" + TEST_VERSION + "\""), formula); + } + + @Test + void snapshotVersionFailsBeforeReachingJReleaser(@TempDir Path tmp) throws Exception { + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = new LinkedHashMap<>(testModeEnvWithMvnStub(tmp, recordFile)); + env.put("CAMEL_PACKAGE_TEST_VERSION", "9.9.9-SNAPSHOT"); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertNotEquals(0, r.exit, "a snapshot version must be rejected"); + assertFalse(Files.exists(recordFile), "JReleaser must never be invoked for a snapshot version"); + assertFalse(Files.exists(websiteDir())); + } + + @Test + void missingArtifactFailsBeforeReachingJReleaser(@TempDir Path tmp) throws Exception { + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertNotEquals(0, r.exit, "missing release artifacts must fail before JReleaser runs"); + assertFalse(Files.exists(recordFile)); + assertFalse(Files.exists(websiteDir())); + } + + @Test + void testVersionOverrideRequiresExplicitTestMode(@TempDir Path tmp) throws Exception { + Path recordFile = tmp.resolve("mvn-calls.txt"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + env.remove("CAMEL_PACKAGE_TEST_MODE"); + + Result r = run(env, "prepare", "--channel", "stable"); + + assertEquals(2, r.exit, + "CAMEL_PACKAGE_TEST_VERSION alone (without test mode) must be a fatal usage error"); + assertTrue(r.stderr.toLowerCase().contains("test_mode"), r.stderr); + assertFalse(Files.exists(recordFile)); + } + + // --- XML helpers: the POM and assembly descriptors are asserted structurally, so reformatting them + // (or reordering elements) cannot break these tests while a real content change slips through. + + private static Document parseXml(Path file) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + try (var in = Files.newInputStream(file)) { + return factory.newDocumentBuilder().parse(in); + } + } + + /** Text of every descendant element with the given tag name, in document order. */ + private static List elementTexts(Node scope, String tagName) { + List texts = new ArrayList<>(); + NodeList nodes = scope instanceof Document doc + ? doc.getElementsByTagName(tagName) : ((Element) scope).getElementsByTagName(tagName); + for (int i = 0; i < nodes.getLength(); i++) { + texts.add(nodes.item(i).getTextContent().trim()); + } + return texts; + } + + /** + * Every {@code } an assembly descriptor contributes, following its {@code } + * references so the assertion describes what the archive actually carries rather than which file happens to declare + * it. + */ + private static List effectiveIncludes(Path descriptor) throws Exception { + Document document = parseXml(descriptor); + List includes = new ArrayList<>(elementTexts(document, "include")); + for (String component : elementTexts(document, "componentDescriptor")) { + includes.addAll(elementTexts(parseXml(MODULE_DIR.resolve(component)), "include")); + } + return includes; + } + + private static Element firstChild(Element parent, String tagName) { + NodeList children = parent.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child instanceof Element element && element.getTagName().equals(tagName)) { + return element; + } + } + return null; + } + + /** The {@code } whose direct {@code } child matches, or null. */ + private static Element profileById(Document pom, String id) { + return findByChildId(pom.getElementsByTagName("profile"), id); + } + + /** The {@code } within the given scope whose direct {@code } child matches, or null. */ + private static Element executionById(Element scope, String id) { + return findByChildId(scope.getElementsByTagName("execution"), id); + } + + private static Element findByChildId(NodeList candidates, String id) { + for (int i = 0; i < candidates.getLength(); i++) { + Element candidate = (Element) candidates.item(i); + Element idElement = firstChild(candidate, "id"); + if (idElement != null && idElement.getTextContent().trim().equals(id)) { + return candidate; + } + } + return null; + } +} 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 c5672a1deb8ea..c7ba6c893d9b1 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 @@ -970,10 +970,10 @@ void rejectsEscapingReparsePointEntry(@TempDir Path temp) throws Exception { } @Test - void rejectsArchiveMissingCamelExe(@TempDir Path temp) throws Exception { + void rejectsArchiveMissingCamelBat(@TempDir Path temp) throws Exception { try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { Path home = Files.createDirectory(temp.resolve("home")); - Path zip = fixture.safeZipMissingCamelExe("9.9.9"); + Path zip = fixture.safeZipMissingCamelBat("9.9.9"); Path tar = fixture.safeTar("9.9.9"); publishRelease(fixture, "9.9.9", tar, zip); fixture.publishManifest("/camel-cli/releases/9.9.9.properties", "9.9.9", tar, zip); 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 af607f526261f..1ac187de45d6c 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 @@ -255,19 +255,20 @@ Path safeTarMissingCamelSh(String version) throws Exception { List entries = List.of( ArchiveEntrySpec.dir(root + "/"), ArchiveEntrySpec.dir(root + "/bin/"), - ArchiveEntrySpec.file(root + "/bin/camel.bat", batScript(version).getBytes(StandardCharsets.UTF_8), false)); + ArchiveEntrySpec.file(root + "/bin/camel.bat", batScript(version, 0).getBytes(StandardCharsets.UTF_8), + false)); Path archive = Files.createTempFile(tempDir, "missing-camel-sh-", "-" + version + ".tar.gz"); writeTarGz(archive, entries); return archive; } - Path safeZipMissingCamelExe(String version) throws Exception { + Path safeZipMissingCamelBat(String version) throws Exception { String root = "camel-launcher-" + version; List entries = List.of( ArchiveEntrySpec.dir(root + "/"), ArchiveEntrySpec.dir(root + "/bin/"), - ArchiveEntrySpec.file(root + "/bin/camel.bat", batScript(version).getBytes(StandardCharsets.UTF_8), false)); - Path archive = Files.createTempFile(tempDir, "missing-camel-exe-", "-" + version + ".zip"); + ArchiveEntrySpec.file(root + "/bin/camel.sh", shScript(version, 0).getBytes(StandardCharsets.UTF_8), true)); + Path archive = Files.createTempFile(tempDir, "missing-camel-bat-", "-" + version + ".zip"); writeZip(archive, entries); return archive; } @@ -295,15 +296,12 @@ private static List launcherEntries(String version) throws Exc private static List launcherEntries(String version, int versionExitCode) throws Exception { String root = "camel-launcher-" + version; byte[] sh = shScript(version, versionExitCode).getBytes(StandardCharsets.UTF_8); - byte[] bat = batScript(version).getBytes(StandardCharsets.UTF_8); - byte[] exe = exeExecutable(version, versionExitCode); + byte[] bat = batScript(version, versionExitCode).getBytes(StandardCharsets.UTF_8); return List.of( ArchiveEntrySpec.dir(root + "/"), ArchiveEntrySpec.dir(root + "/bin/"), ArchiveEntrySpec.file(root + "/bin/camel.sh", sh, true), - ArchiveEntrySpec.file(root + "/bin/camel.bat", bat, false), - ArchiveEntrySpec.file(root + "/bin/camel-x64.exe", exe, true), - ArchiveEntrySpec.file(root + "/bin/camel-arm64.exe", exe, true)); + ArchiveEntrySpec.file(root + "/bin/camel.bat", bat, false)); } private static String shScript(String version, int versionExitCode) { @@ -316,70 +314,14 @@ private static String shScript(String version, int versionExitCode) { + "echo \"Camel " + version + "\"\n"; } - private static String batScript(String version) { + private static String batScript(String version, int versionExitCode) { return "@echo off\r\n" - + "if \"%~1\"==\"version\" (echo Camel " + version + "& exit /b 0)\r\n" + + "if \"%~1\"==\"version\" (echo Camel " + version + "& exit /b " + versionExitCode + ")\r\n" + "if \"%~1\"==\"echo-args\" (shift & echo %*& exit /b 0)\r\n" + "if \"%~1\"==\"exit-code\" (exit /b %2)\r\n" + "echo Camel " + version + "\r\n"; } - /** - * On Windows, install.ps1 executes the staged {@code camel-x64.exe} directly by path to verify Java 17+ before - * activation, so the fixture entry must be a genuine PE, not placeholder text. It is compiled on the fly with the - * .NET Framework compiler that ships with every Windows Powershell 5.1+ install (via {@code Add-Type}), mirroring - * the runtime keystore generation already used for the HTTPS fixture: nothing binary is committed. Off Windows the - * bytes are never executed, only checked for presence in the archive. - */ - private static byte[] exeExecutable(String version, int versionExitCode) throws IOException, InterruptedException { - if (!FakeJava.WINDOWS) { - return ("camel-launcher-" + version + "-test-exe\n").getBytes(StandardCharsets.UTF_8); - } - Path source = Files.createTempFile("camel-test-launcher-", ".cs"); - Path output = Files.createTempFile("camel-test-launcher-", ".exe"); - Files.delete(output); - try { - String csharp = "using System;\n" - + "class Program {\n" - + " static int Main(string[] args) {\n" - + " if (args.Length > 0 && args[0] == \"version\") {\n" - + " Console.WriteLine(\"Camel " + version + "\");\n" - + " return " + versionExitCode + ";\n" - + " }\n" - + " if (args.Length > 0 && args[0] == \"echo-args\") {\n" - + " Console.WriteLine(string.Join(\" \", args, 1, args.Length - 1));\n" - + " return 0;\n" - + " }\n" - + " if (args.Length > 1 && args[0] == \"exit-code\") {\n" - + " return int.Parse(args[1]);\n" - + " }\n" - + " Console.WriteLine(\"Camel " + version + "\");\n" - + " return 0;\n" - + " }\n" - + "}\n"; - Files.writeString(source, csharp, StandardCharsets.UTF_8); - - String psCommand = "Add-Type -OutputType ConsoleApplication -Language CSharp " - + "-OutputAssembly '" + output + "' " - + "-TypeDefinition (Get-Content -Raw -LiteralPath '" + source + "')"; - ProcessBuilder pb = new ProcessBuilder("powershell", "-NoProfile", "-NonInteractive", "-Command", psCommand); - pb.redirectErrorStream(true); - Process process = pb.start(); - String log = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - if (!process.waitFor(60, TimeUnit.SECONDS)) { - process.destroyForcibly(); - throw new IllegalStateException("compiling test camel.exe did not finish in time"); - } - if (process.exitValue() != 0 || !Files.exists(output)) { - throw new IllegalStateException("failed to compile test camel.exe: " + log); - } - return Files.readAllBytes(output); - } finally { - Files.deleteIfExists(source); - Files.deleteIfExists(output); - } - } - private void writeTarGz(Path archive, List entries) throws IOException { try (var fos = Files.newOutputStream(archive); var gzos = new GzipCompressorOutputStream(fos); diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java index 6837ad82e9e8b..382af2635d1ce 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteManifestGeneratorTest.java @@ -24,6 +24,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Collections; import java.util.HexFormat; import java.util.List; import java.util.concurrent.TimeUnit; @@ -56,9 +57,7 @@ private Result run(String... args) throws Exception { List cmd = new ArrayList<>(); cmd.add(javaBin); cmd.add(GENERATOR.toString()); - for (String a : args) { - cmd.add(a); - } + Collections.addAll(cmd, args); Process p = new ProcessBuilder(cmd).start(); String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java new file mode 100644 index 0000000000000..94b5652238171 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java @@ -0,0 +1,320 @@ +/* + * 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.Paths; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WingetDistroScriptsTest { + + private static final Path ROOT = Paths.get("").toAbsolutePath().resolve("../../..").normalize(); + private static final Path STAGE_SCRIPT = ROOT.resolve("etc/scripts/stage-winget-distro.sh"); + private static final Path RELEASE_SCRIPT = ROOT.resolve("etc/scripts/release-distro.sh"); + private static final String VERSION = "9.9.9"; + private static final String FILE_NAME = "camel-launcher-9.9.9-winget-bin.zip"; + private static final String APPROVED_BYTES = "approved-winget-bytes"; + + @Test + void stageScriptCreatesSignedChecksummedUncommittedCandidate(@TempDir Path tmp) throws Exception { + Path zip = tmp.resolve(FILE_NAME); + Files.writeString(zip, "approved-winget-bytes", StandardCharsets.UTF_8); + Path calls = tmp.resolve("calls.txt"); + Path bin = tmp.resolve("bin"); + Files.createDirectories(bin); + writeExecutable(bin.resolve("svn"), + "#!/bin/sh\n" + + "printf 'svn %s\\n' \"$*\" >> \"" + calls + "\"\n" + + "if [ \"$1\" = checkout ]; then\n" + + " for destination in \"$@\"; do :; done\n" + + " mkdir -p \"$destination\"\n" + + "fi\n"); + writeExecutable(bin.resolve("gpg"), + "#!/bin/sh\n" + + "output=''\n" + + "input=''\n" + + "while [ $# -gt 0 ]; do\n" + + " case \"$1\" in\n" + + " --output) output=$2; shift 2 ;;\n" + + " *) input=$1; shift ;;\n" + + " esac\n" + + "done\n" + + "printf 'signature for %s\\n' \"$input\" > \"$output\"\n"); + + ProcessBuilder pb = new ProcessBuilder( + "bash", STAGE_SCRIPT.toString(), VERSION, "1", zip.toString(), + tmp.resolve("work").toString()); + pb.environment().put("PATH", bin + File.pathSeparator + System.getenv("PATH")); + Process process = pb.start(); + String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(30, TimeUnit.SECONDS)); + assertEquals(0, process.exitValue(), stderr); + + Path candidate = tmp.resolve("work/dist-dev/" + VERSION + "-rc1"); + Path stagedZip = candidate.resolve(FILE_NAME); + assertEquals("approved-winget-bytes", Files.readString(stagedZip, StandardCharsets.UTF_8)); + assertTrue(Files.exists(candidate.resolve(FILE_NAME + ".asc"))); + String expectedSha512 = HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-512").digest(Files.readAllBytes(stagedZip))); + assertEquals(expectedSha512 + " " + FILE_NAME + "\n", + Files.readString(candidate.resolve(FILE_NAME + ".sha512"), StandardCharsets.UTF_8)); + String recorded = Files.readString(calls, StandardCharsets.UTF_8); + assertTrue(recorded.contains("svn checkout --depth immediates"), recorded); + assertTrue(recorded.contains("svn add " + VERSION + "-rc1"), recorded); + assertFalse(recorded.contains(" commit "), "the staging script must stop before remote mutation"); + assertFalse(recorded.contains(" ci "), "the staging script must stop before remote mutation"); + } + + @Test + void stageScriptRejectsAFileWithTheWrongReleaseName(@TempDir Path tmp) throws Exception { + Path zip = tmp.resolve("wrong.zip"); + Files.writeString(zip, "bytes", StandardCharsets.UTF_8); + + Process process = new ProcessBuilder( + "bash", STAGE_SCRIPT.toString(), VERSION, "1", zip.toString(), + tmp.resolve("work").toString()).start(); + String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(30, TimeUnit.SECONDS)); + + assertEquals(2, process.exitValue()); + assertTrue(stderr.contains(FILE_NAME), stderr); + } + + @Test + void stageScriptRejectsSnapshots(@TempDir Path tmp) throws Exception { + String snapshot = VERSION + "-SNAPSHOT"; + Path zip = tmp.resolve("camel-launcher-" + snapshot + "-winget-bin.zip"); + Files.writeString(zip, "bytes", StandardCharsets.UTF_8); + + Process process = new ProcessBuilder( + "bash", STAGE_SCRIPT.toString(), snapshot, "1", zip.toString(), + tmp.resolve("work").toString()).start(); + String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(30, TimeUnit.SECONDS)); + + assertEquals(2, process.exitValue()); + assertTrue(stderr.contains("refusing to stage snapshot version"), stderr); + } + + @Test + void stageScriptRejectsCandidateZero(@TempDir Path tmp) throws Exception { + Path zip = tmp.resolve(FILE_NAME); + Files.writeString(zip, "bytes", StandardCharsets.UTF_8); + + Process process = new ProcessBuilder( + "bash", STAGE_SCRIPT.toString(), VERSION, "0", zip.toString(), + tmp.resolve("work").toString()).start(); + String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(30, TimeUnit.SECONDS)); + + assertEquals(2, process.exitValue()); + assertTrue(stderr.contains("candidate number must be a positive integer"), stderr); + } + + /** + * Promotion must copy the voted bytes through unchanged. The approved candidate is exported from dist/dev and lands + * in the dist/release working copy byte-for-byte, having been checksum- and signature-verified on the way. + */ + @Test + void releaseScriptPromotesTheApprovedCandidateByteForByte(@TempDir Path tmp) throws Exception { + ReleaseHarness harness = new ReleaseHarness(tmp, APPROVED_BYTES, true); + + Process process = harness.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(60, TimeUnit.SECONDS)); + assertEquals(0, process.exitValue(), output); + + Path promoted = harness.distDir.resolve(VERSION + "/" + FILE_NAME); + assertEquals(APPROVED_BYTES, Files.readString(promoted, StandardCharsets.UTF_8), + "the promoted payload must be the exact bytes exported from dist/dev, never a rebuild"); + assertTrue(Files.exists(harness.distDir.resolve(VERSION + "/" + FILE_NAME + ".asc")), + "the detached signature must be promoted alongside the payload"); + assertTrue(Files.exists(harness.distDir.resolve(VERSION + "/" + FILE_NAME + ".sha512"))); + assertTrue(harness.recordedSvn().contains("export"), harness.recordedSvn()); + assertTrue(harness.recordedGpg().contains("--verify"), + "the exported candidate must have its signature checked: " + harness.recordedGpg()); + } + + /** A tampered payload must stop promotion before anything reaches the dist/release working copy. */ + @Test + void releaseScriptRefusesACandidateThatFailsChecksumVerification(@TempDir Path tmp) throws Exception { + ReleaseHarness harness = new ReleaseHarness(tmp, APPROVED_BYTES, false); + + Process process = harness.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(60, TimeUnit.SECONDS)); + + assertNotEquals(0, process.exitValue(), "a checksum mismatch must abort promotion: " + output); + assertTrue(output.contains("SHA-512 verification failed"), output); + assertFalse(Files.exists(harness.distDir.resolve(VERSION + "/" + FILE_NAME)), + "nothing may reach the dist/release working copy after a failed verification"); + } + + /** + * Pointing at the wrong RC directory is the dangerous case: every candidate carries its own self-consistent .sha512 + * and .asc, so those checks pass on a superseded candidate. Only the digest carried in the vote email distinguishes + * them, so a mismatch must stop promotion. + */ + @Test + void releaseScriptRefusesACandidateThatIsNotTheOneTheVoteApproved(@TempDir Path tmp) throws Exception { + ReleaseHarness harness = new ReleaseHarness(tmp, "bytes-of-a-superseded-candidate", true); + + Process process = harness.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(60, TimeUnit.SECONDS)); + + assertNotEquals(0, process.exitValue(), "a candidate the vote did not approve must abort promotion: " + output); + assertTrue(output.contains("does not match the SHA-512 approved in the vote"), output); + assertFalse(Files.exists(harness.distDir.resolve(VERSION + "/" + FILE_NAME)), + "a superseded candidate must never reach the dist/release working copy"); + } + + @Test + void releaseScriptRequiresTheApprovedDigestAlongsideACandidateNumber(@TempDir Path tmp) throws Exception { + ReleaseHarness harness = new ReleaseHarness(tmp, APPROVED_BYTES, true); + + Process process = harness.start(null); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(60, TimeUnit.SECONDS)); + + assertNotEquals(0, process.exitValue(), output); + assertTrue(output.contains("winget-sha512"), output); + } + + /** + * Drives etc/scripts/release-distro.sh against stubbed wget/svn/gpg/chown/chmod so the real promotion flow runs + * without network or ASF credentials. The svn stub serves the dist/dev candidate and backs the dist/release + * checkout; {@code corruptChecksum} makes the exported .sha512 describe different bytes than the exported ZIP. + */ + private final class ReleaseHarness { + final Path download; + final Path distDir; + private final Path binDir; + private final Path svnCalls; + private final Path gpgCalls; + + ReleaseHarness(Path tmp, String payload, boolean validChecksum) throws Exception { + download = tmp.resolve("download"); + distDir = download.resolve("dist"); + binDir = tmp.resolve("bin"); + svnCalls = tmp.resolve("svn-calls.txt"); + gpgCalls = tmp.resolve("gpg-calls.txt"); + Files.createDirectories(binDir); + Files.createDirectories(download); + + Path artifacts = download.resolve(VERSION + "/org/apache/camel/apache-camel/" + VERSION); + Files.createDirectories(artifacts); + // What the real wget -r pulls down from the Nexus release repository, including the sidecars + // release-distro.sh strips before generating its own SHA-512 files. + for (String name : List.of("apache-camel-" + VERSION + ".pom", "apache-camel-" + VERSION + ".tar.gz", + "apache-camel-" + VERSION + ".zip")) { + Files.writeString(artifacts.resolve(name), name, StandardCharsets.UTF_8); + for (String sidecar : List.of(".asc", ".asc.asc", ".md5", ".sha1")) { + Files.writeString(artifacts.resolve(name + sidecar), "sidecar", StandardCharsets.UTF_8); + } + } + + writeExecutable(binDir.resolve("wget"), "#!/bin/sh\nexit 0\n"); + writeExecutable(binDir.resolve("chown"), "#!/bin/sh\nexit 0\n"); + writeExecutable(binDir.resolve("chmod"), "#!/bin/sh\nexit 0\n"); + writeExecutable(binDir.resolve("gpg"), + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"" + gpgCalls + "\"\nexit 0\n"); + writeExecutable(binDir.resolve("svn"), svnStub(payload, validChecksum)); + } + + /** + * Handles the four svn verbs release-distro.sh uses. `export` serves the approved candidate: the ZIP gets the + * payload, the .sha512 is computed from whatever the ZIP actually holds (or from different bytes when the test + * wants a mismatch), and the .asc is a placeholder the stubbed gpg accepts. + */ + private String svnStub(String payload, boolean validChecksum) { + String checksumSource = validChecksum ? "$destination_zip" : "-"; + return "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> \"" + svnCalls + "\"\n" + + "verb=$1\n" + + "case \"$verb\" in\n" + + " export)\n" + + " destination=$3\n" + + " destination_zip=${destination%.asc}\n" + + " destination_zip=${destination_zip%.sha512}\n" + + " case \"$destination\" in\n" + + " *.sha512)\n" + + " printf 'tampered' | sha512sum " + checksumSource + " \\\n" + + " | sed \"s#[ ].*# " + FILE_NAME + "#\" > \"$destination\" ;;\n" + + " *.asc) printf 'signature\\n' > \"$destination\" ;;\n" + + " *) printf '%s' '" + payload + "' > \"$destination\" ;;\n" + + " esac ;;\n" + + " co|checkout)\n" + + " mkdir -p \"" + distDir + "/" + VERSION + "\" ;;\n" + + " mkdir|add) : ;;\n" + + "esac\n" + + "exit 0\n"; + } + + /** Runs promotion with the digest the vote approved, i.e. the digest of {@link #APPROVED_BYTES}. */ + Process start() throws Exception { + return start(sha512Hex(APPROVED_BYTES)); + } + + /** Runs promotion with an explicit approved digest, or omits the argument entirely when null. */ + Process start(String approvedDigest) throws Exception { + List command = new ArrayList<>( + List.of("bash", RELEASE_SCRIPT.toString(), VERSION, download.toString(), "1")); + if (approvedDigest != null) { + command.add(approvedDigest); + } + ProcessBuilder pb = new ProcessBuilder(command); + pb.environment().put("PATH", binDir + File.pathSeparator + System.getenv("PATH")); + pb.redirectErrorStream(true); + return pb.start(); + } + + String recordedSvn() throws IOException { + return Files.exists(svnCalls) ? Files.readString(svnCalls, StandardCharsets.UTF_8) : ""; + } + + String recordedGpg() throws IOException { + return Files.exists(gpgCalls) ? Files.readString(gpgCalls, StandardCharsets.UTF_8) : ""; + } + } + + private static String sha512Hex(String content) throws Exception { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-512").digest(content.getBytes(StandardCharsets.UTF_8))); + } + + private static void writeExecutable(Path path, String content) throws Exception { + Files.writeString(path, content, StandardCharsets.UTF_8); + assertTrue(path.toFile().setExecutable(true)); + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/resources/supported-lts-test-fixture.yml b/dsl/camel-jbang/camel-launcher/src/test/resources/supported-lts-test-fixture.yml new file mode 100644 index 0000000000000..f5b0236b2df2a --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/resources/supported-lts-test-fixture.yml @@ -0,0 +1,24 @@ +# +# 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. +# + +# Synthetic LTS allowlist for PackagePlanTest (loaded via CAMEL_PACKAGE_TEST_SUPPORTED_LTS), kept +# separate from the real supported-lts.yml so LTS-expiry assertions never expire on their own. +supported: + - line: "9.9" + supportEnds: "9999-12-31" + - line: "1.0" + supportEnds: "2000-01-01" diff --git a/etc/scripts/release-distro.sh b/etc/scripts/release-distro.sh index 13d15171f7c0d..a6a68722896cd 100755 --- a/etc/scripts/release-distro.sh +++ b/etc/scripts/release-distro.sh @@ -18,17 +18,46 @@ VERSION=${1} DOWNLOAD=${2:-/tmp/camel-release} +WINGET_CANDIDATE=${3:-} +WINGET_SHA512=${4:-} mkdir -p ${DOWNLOAD} 2>/dev/null # The following component contain schema definitions that must be published RUNDIR=$(cd ${0%/*} && echo $PWD) DIST_REPO="https://dist.apache.org/repos/dist/release/camel/apache-camel/" +DIST_DEV_REPO="https://dist.apache.org/repos/dist/dev/camel/apache-camel" if [ -z "${VERSION}" -o ! -d "${DOWNLOAD}" ] then - echo "Usage: release-distro.sh [temp-directory]" + echo "Usage: release-distro.sh [temp-directory] [winget-candidate] [winget-sha512]" exit 1 fi +case "${WINGET_CANDIDATE}" in + *[!0-9]*) + echo "Error: winget-candidate must be a positive integer." + exit 1 + ;; +esac +if [ -n "${WINGET_CANDIDATE}" ] && [ "${WINGET_CANDIDATE}" -lt 1 ]; then + echo "Error: winget-candidate must be a positive integer." + exit 1 +fi +# The candidate number alone cannot prove the right candidate was promoted: every RC directory +# carries its own self-consistent .sha512 and .asc, so exporting rc3 when the vote approved rc4 +# passes both checks. The approved digest comes from the vote email and is the only input that +# distinguishes them, so it is mandatory whenever a candidate is promoted. +if [ -n "${WINGET_CANDIDATE}" ]; then + case "${WINGET_SHA512}" in + *[!0-9a-fA-F]* | "") + echo "Error: winget-sha512 (the approved SHA-512 from the vote email) is required with winget-candidate." + exit 1 + ;; + esac + if [ ${#WINGET_SHA512} -ne 128 ]; then + echo "Error: winget-sha512 must be a 128-character SHA-512 hex digest." + exit 1 + fi +fi echo "################################################################################" echo " DOWNLOADING DISTRO FROM APACHE REPOSITORY " @@ -55,6 +84,34 @@ for file in *.pom *.tar.gz *.zip; do [ -f "${file}" ] && sha512sum "${file}" > "${file}.sha512" done +if [ -n "${WINGET_CANDIDATE}" ]; then + WINGET_NAME="camel-launcher-${VERSION}-winget-bin.zip" + WINGET_RC_URL="${DIST_DEV_REPO}/${VERSION}-rc${WINGET_CANDIDATE}" + for suffix in "" ".asc" ".sha512"; do + if ! svn export "${WINGET_RC_URL}/${WINGET_NAME}${suffix}" \ + "${DOWNLOAD_LOCATION}/${WINGET_NAME}${suffix}"; then + echo "Error: could not export approved WinGet candidate file ${WINGET_NAME}${suffix}." + exit 1 + fi + done + ACTUAL_SHA512=$(sha512sum "${WINGET_NAME}" | awk '{print $1}') + if [ "$(echo "${ACTUAL_SHA512}" | tr 'A-Z' 'a-z')" != "$(echo "${WINGET_SHA512}" | tr 'A-Z' 'a-z')" ]; then + echo "Error: exported WinGet candidate does not match the SHA-512 approved in the vote." + echo " approved: ${WINGET_SHA512}" + echo " exported: ${ACTUAL_SHA512} (from ${WINGET_RC_URL})" + echo "Check that rc${WINGET_CANDIDATE} is the candidate the vote actually approved." + exit 1 + fi + if ! sha512sum -c "${WINGET_NAME}.sha512"; then + echo "Error: approved WinGet candidate SHA-512 verification failed." + exit 1 + fi + if ! gpg --verify "${WINGET_NAME}.asc" "${WINGET_NAME}"; then + echo "Error: approved WinGet candidate signature verification failed." + exit 1 + fi +fi + echo "################################################################################" echo " RESET GROUP PERMISSIONS " echo "################################################################################" diff --git a/etc/scripts/stage-winget-distro.sh b/etc/scripts/stage-winget-distro.sh new file mode 100755 index 0000000000000..c96087f97a344 --- /dev/null +++ b/etc/scripts/stage-winget-distro.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# 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. +# + +set -euo pipefail + +usage() { + echo "Usage: stage-winget-distro.sh [work-directory]" 1>&2 + exit 2 +} + +VERSION=${1:-} +CANDIDATE=${2:-} +ZIP=${3:-} +WORK_DIR=${4:-/tmp/camel-winget-release} +DIST_DEV_REPO="https://dist.apache.org/repos/dist/dev/camel/apache-camel" + +[ -n "$VERSION" ] && [ -n "$CANDIDATE" ] && [ -n "$ZIP" ] || usage +case "$VERSION" in + *-SNAPSHOT) echo "Error: refusing to stage snapshot version '$VERSION'." 1>&2; exit 2 ;; +esac +case "$CANDIDATE" in + ''|*[!0-9]*) echo "Error: candidate number must be a positive integer." 1>&2; exit 2 ;; +esac +if [ "$CANDIDATE" -lt 1 ]; then + echo "Error: candidate number must be a positive integer." 1>&2 + exit 2 +fi + +FILE_NAME="camel-launcher-$VERSION-winget-bin.zip" +if [ "$(basename -- "$ZIP")" != "$FILE_NAME" ]; then + echo "Error: expected WinGet ZIP filename '$FILE_NAME'." 1>&2 + exit 2 +fi +if [ ! -f "$ZIP" ]; then + echo "Error: WinGet ZIP not found: $ZIP" 1>&2 + exit 1 +fi + +command -v svn >/dev/null 2>&1 || { echo "Error: svn is required." 1>&2; exit 1; } +command -v gpg >/dev/null 2>&1 || { echo "Error: gpg is required." 1>&2; exit 1; } +command -v sha512sum >/dev/null 2>&1 || { echo "Error: sha512sum is required." 1>&2; exit 1; } + +SVN_DIR="$WORK_DIR/dist-dev" +CANDIDATE_NAME="$VERSION-rc$CANDIDATE" +CANDIDATE_DIR="$SVN_DIR/$CANDIDATE_NAME" +if [ -e "$CANDIDATE_DIR" ]; then + echo "Error: candidate working directory already exists: $CANDIDATE_DIR" 1>&2 + exit 1 +fi + +mkdir -p "$WORK_DIR" +svn checkout --depth immediates "$DIST_DEV_REPO" "$SVN_DIR" +mkdir "$CANDIDATE_DIR" +cp -p "$ZIP" "$CANDIDATE_DIR/$FILE_NAME" +# Signs with gpg's default key, matching maven-gpg-plugin's behaviour for the rest of the +# release. Release managers holding more than one key select theirs with CAMEL_GPG_KEY, the +# equivalent of maven-gpg-plugin's gpg.keyname. +GPG_KEY_ARGS=() +if [ -n "${CAMEL_GPG_KEY:-}" ]; then + GPG_KEY_ARGS=(--local-user "$CAMEL_GPG_KEY") +fi +gpg --batch --verbose --armor --detach-sign "${GPG_KEY_ARGS[@]}" \ + --output "$CANDIDATE_DIR/$FILE_NAME.asc" "$CANDIDATE_DIR/$FILE_NAME" +( + cd "$CANDIDATE_DIR" + sha512sum "$FILE_NAME" > "$FILE_NAME.sha512" +) +( + cd "$SVN_DIR" + svn add "$CANDIDATE_NAME" +) + +echo "WinGet candidate prepared, but not committed. Review it before upload:" +echo "cd $SVN_DIR" +echo "svn status" +echo "svn commit -m \"Apache Camel $VERSION WinGet RC$CANDIDATE\"" +echo "Candidate URL after commit: $DIST_DEV_REPO/$CANDIDATE_NAME/" +echo +# release-distro.sh requires this digest to promote the candidate, so it has to travel with the +# vote: the RC number alone cannot distinguish an approved candidate from a superseded one. +echo "Include this digest in the vote email; release-distro.sh requires it to promote the candidate:" +echo " SHA-512 ($FILE_NAME): $(awk '{print $1}' "$CANDIDATE_DIR/$FILE_NAME.sha512")" diff --git a/pom.xml b/pom.xml index 4d0ac5131ca7d..ad143e2204c71 100644 --- a/pom.xml +++ b/pom.xml @@ -618,8 +618,16 @@ false clean install deploy - - -Prelease + + -Prelease -Dcamel.exe.build=true true @@ -749,6 +757,13 @@ true + true diff --git a/tooling/camel-exe/README.md b/tooling/camel-exe/README.md index 7eb3f15e55f48..f1a2cdbb7e685 100644 --- a/tooling/camel-exe/README.md +++ b/tooling/camel-exe/README.md @@ -11,10 +11,10 @@ the `camel` command. A `.bat` or `.cmd` shim is not enough — it produces a bro `camel.exe` symlink. This module compiles minimal native binaries that satisfy that contract. -`camel-x64.exe` / `camel-arm64.exe` do not embed Java or run Camel. They locate -`camel.bat` in the same directory, forward the caller's command line (preserving spaces -and Unicode), and return its exit code. All Java discovery and CLI parsing remain in -`camel.bat` and the launcher JAR. +`camel-x64.exe` / `camel-arm64.exe` do not embed Java or run Camel. They resolve WinGet's +`camel.exe` symlink, locate `camel.bat` beside the extracted target, forward the caller's +command line (preserving spaces and Unicode), and return its exit code. All Java discovery +and CLI parsing remain in `camel.bat` and the launcher JAR. ## Why a separate module? @@ -26,8 +26,8 @@ native bootstrap is ~100 lines of C with **no Java dependencies**. Keeping it he tests the exe without building the full jbang graph. - **Clear separation** — packaging bootstrap vs. runtime launcher. - **Reusable artifact** — `camel-launcher` copies the attached `exe` artifacts into its - distribution (`bin/camel-x64.exe` and `bin/camel-arm64.exe` inside - `camel-launcher-*-bin.zip`). + WinGet-only distribution (`bin/camel-x64.exe` and `bin/camel-arm64.exe` inside + `camel-launcher-*-winget-bin.zip`). ## Build and test @@ -38,11 +38,17 @@ on `PATH`. Works on Linux, macOS, or Windows — no host-OS restriction. mvn -pl tooling/camel-exe verify -Dcamel.exe.build=true ``` -Release and integration builds that produce the launcher ZIP also build this module first: +Release and integration builds that produce the WinGet launcher ZIP also build this module first: ```bash mvn -pl tooling/camel-exe,dsl/camel-jbang/camel-launcher -am verify -Dcamel.exe.build=true ``` +The native launcher workflow runs this build twice from clean output directories and compares the +SHA-256 values of `camel-x64.exe`, `camel-arm64.exe`, and the WinGet ZIP. It also deploys the reactor +to a temporary file repository and fails if Maven publishes the WinGet ZIP or a corresponding +`.sha1` sidecar. A checksum difference is a release failure and must be investigated before changing +compiler or linker flags. + See [src/main/native/README.md](src/main/native/README.md) for toolchain setup, compiler flags, and the release-gate profile. diff --git a/tooling/camel-exe/pom.xml b/tooling/camel-exe/pom.xml index 847e061bbcbb5..6a3a2bec4600e 100644 --- a/tooling/camel-exe/pom.xml +++ b/tooling/camel-exe/pom.xml @@ -110,8 +110,10 @@ - Plain 'mvn clean install' (any OS): no-op, unaffected. - '-Dcamel.exe.build=true' (CI, manual): attempts the cross-compile, requires llvm-mingw on PATH. - - '-Prelease' (real releases): auto-triggers via the release profile - setting camel.exe.build=true. + - Real releases: maven-release-plugin passes -Dcamel.exe.build=true in its + , alongside -Prelease. '-Prelease' on its own is NOT enough: + the release profile sets camel.exe.build as a project property, and Maven's + property activator only reads user and system properties. --> build-native-exe @@ -142,6 +144,14 @@ -static -mconsole -municode + + -Wl,--no-insert-timestamp -o ${project.build.directory}/camel-x64.exe ${project.basedir}/src/main/native/camel.c @@ -163,6 +173,8 @@ -static -mconsole -municode + + -Wl,--no-insert-timestamp -o ${project.build.directory}/camel-arm64.exe ${project.basedir}/src/main/native/camel.c diff --git a/tooling/camel-exe/src/main/native/camel.c b/tooling/camel-exe/src/main/native/camel.c index 8f77121d10468..af3cb69384edb 100644 --- a/tooling/camel-exe/src/main/native/camel.c +++ b/tooling/camel-exe/src/main/native/camel.c @@ -17,10 +17,11 @@ /* * camel.exe - minimal WinGet-compatible bootstrap for the Camel CLI launcher. * - * It locates the camel.bat sitting in the same directory, forwards the caller's - * exact command-line tail (preserving Unicode and Windows quoting), inherits the - * standard streams, and returns the child process exit code. It performs NO Java - * discovery and NO Camel option parsing; that all lives in camel.bat. + * It resolves its final target when launched through a WinGet symlink, locates + * the camel.bat sitting beside that target, forwards the caller's exact + * command-line tail (preserving Unicode and Windows quoting), inherits the + * standard streams, and returns the child process exit code. It performs NO + * Java discovery and NO Camel option parsing; that all lives in camel.bat. */ #include @@ -51,12 +52,11 @@ static LPWSTR skip_argv0(LPWSTR cmd) { } /* - * Return a heap-allocated wide string holding the directory that contains this - * exe, or NULL on failure. The caller must free() the result. Uses a doubling - * loop so paths beyond MAX_PATH (260) work on Windows 10+ with long-path - * support enabled. + * Return a heap-allocated wide string holding this exe's module path, or NULL + * on failure. The caller must free() the result. Uses a doubling loop so paths + * beyond MAX_PATH (260) work on Windows 10+ with long-path support enabled. */ -static wchar_t *get_exe_dir(void) { +static wchar_t *get_module_path(void) { DWORD bufSize = MAX_PATH; wchar_t *buf = (wchar_t *) malloc(bufSize * sizeof(wchar_t)); if (buf == NULL) { @@ -79,6 +79,63 @@ static wchar_t *get_exe_dir(void) { } buf = tmp; } + return buf; +} + +/* Resolve WinGet's camel.exe symlink to the extracted executable path. */ +static wchar_t *get_final_path(const wchar_t *modulePath) { + HANDLE file = CreateFileW(modulePath, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) { + return NULL; + } + + DWORD size = GetFinalPathNameByHandleW(file, NULL, 0, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + if (size == 0) { + CloseHandle(file); + return NULL; + } + wchar_t *buf = (wchar_t *) malloc(size * sizeof(wchar_t)); + if (buf == NULL) { + CloseHandle(file); + return NULL; + } + DWORD n = GetFinalPathNameByHandleW(file, buf, size, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + CloseHandle(file); + if (n == 0 || n >= size) { + free(buf); + return NULL; + } + + size_t len = wcslen(buf); + if (wcsncmp(buf, L"\\\\?\\UNC\\", 8) == 0) { + memmove(buf + 2, buf + 8, (len - 7) * sizeof(wchar_t)); + buf[0] = L'\\'; + buf[1] = L'\\'; + } else if (wcsncmp(buf, L"\\\\?\\", 4) == 0) { + memmove(buf, buf + 4, (len - 3) * sizeof(wchar_t)); + } + return buf; +} + +static wchar_t *get_exe_dir(void) { + wchar_t *modulePath = get_module_path(); + if (modulePath == NULL) { + return NULL; + } + /* + * Symlink resolution only matters when WinGet installed us behind its camel.exe alias. + * If it fails (the module cannot be opened for FILE_READ_ATTRIBUTES, or the final path + * cannot be read), fall back to the module path: for a directly-invoked exe, as shipped + * by Chocolatey or a manual extract, that is already the correct location of camel.bat. + */ + wchar_t *buf = get_final_path(modulePath); + if (buf == NULL) { + buf = modulePath; + } else { + free(modulePath); + } wchar_t *slash = wcsrchr(buf, L'\\'); if (slash == NULL) { free(buf); diff --git a/tooling/camel-exe/src/test/java/org/apache/camel/tooling/exe/CamelExeBootstrapTest.java b/tooling/camel-exe/src/test/java/org/apache/camel/tooling/exe/CamelExeBootstrapTest.java index 1b781c0f6e803..ca29372f4eed9 100644 --- a/tooling/camel-exe/src/test/java/org/apache/camel/tooling/exe/CamelExeBootstrapTest.java +++ b/tooling/camel-exe/src/test/java/org/apache/camel/tooling/exe/CamelExeBootstrapTest.java @@ -207,6 +207,24 @@ void worksWhenExeDirectoryHasSpaces(@TempDir Path base) throws Exception { assertTrue(r.stdout.contains("ARG=version"), r.stdout); } + @Test + void resolvesCamelBatBesideTargetWhenLaunchedThroughSymlink(@TempDir Path base) throws Exception { + Path packageDir = base.resolve("package/bin"); + Files.createDirectories(packageDir); + fakeBat(packageDir, 0); + Path target = stagedExe(packageDir); + + Path linksDir = base.resolve("links"); + Files.createDirectories(linksDir); + Path link = linksDir.resolve("camel.exe"); + Files.createSymbolicLink(link, target); + + Result r = run(link, "version"); + + assertEquals(0, r.exit, r.stdout); + assertTrue(r.stdout.contains("ARG=version"), r.stdout); + } + @Test void failsGracefullyWhenCamelBatIsMissing(@TempDir Path dir) throws Exception { Path exe = stagedExe(dir); diff --git a/tooling/maven/camel-repackager-maven-plugin/pom.xml b/tooling/maven/camel-repackager-maven-plugin/pom.xml index 5fa88e2d9eced..2cfdbe36cf2c2 100644 --- a/tooling/maven/camel-repackager-maven-plugin/pom.xml +++ b/tooling/maven/camel-repackager-maven-plugin/pom.xml @@ -68,6 +68,16 @@ ${spring-boot-version} + + + org.apache.maven + maven-archiver + + org.junit.jupiter diff --git a/tooling/maven/camel-repackager-maven-plugin/src/main/java/org/apache/camel/maven/RepackageMojo.java b/tooling/maven/camel-repackager-maven-plugin/src/main/java/org/apache/camel/maven/RepackageMojo.java index 7c1c679b2ea74..b147b48100e9c 100644 --- a/tooling/maven/camel-repackager-maven-plugin/src/main/java/org/apache/camel/maven/RepackageMojo.java +++ b/tooling/maven/camel-repackager-maven-plugin/src/main/java/org/apache/camel/maven/RepackageMojo.java @@ -18,8 +18,10 @@ import java.io.File; import java.io.IOException; +import java.nio.file.attribute.FileTime; import java.util.Set; +import org.apache.maven.archiver.MavenArchiver; import org.apache.maven.artifact.Artifact; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; @@ -47,7 +49,7 @@ public class RepackageMojo extends AbstractMojo { * The Maven project. */ @Parameter(defaultValue = "${project}", readonly = true, required = true) - private MavenProject project; + protected MavenProject project; /** * The source JAR file to repackage. If not specified, uses the project's main artifact. @@ -59,25 +61,31 @@ public class RepackageMojo extends AbstractMojo { * The main class to use for the executable JAR. */ @Parameter(required = true) - private String mainClass; + protected String mainClass; /** * The output directory for the repackaged JAR. */ @Parameter(defaultValue = "${project.build.directory}") - private File outputDirectory; + protected File outputDirectory; /** * The final name of the repackaged JAR (without extension). */ @Parameter(defaultValue = "${project.build.finalName}") - private String finalName; + protected String finalName; /** * Whether to backup the source JAR. */ @Parameter(defaultValue = "true") - private boolean backupSource; + protected boolean backupSource; + + /** + * Timestamp for reproducible output, either formatted as ISO-8601 or as seconds since the epoch. + */ + @Parameter(defaultValue = "${project.build.outputTimestamp}") + protected String outputTimestamp; @Override public void execute() throws MojoExecutionException, MojoFailureException { @@ -94,7 +102,12 @@ public void execute() throws MojoExecutionException, MojoFailureException { repackager.setMainClass(mainClass); File targetFile = getTargetFile(); - repackager.repackage(targetFile, this::getLibraries); + // The last-modified time drives both halves of a reproducible JAR: it pins every entry's + // timestamp, and a non-null value additionally switches BOOT-INF/lib from insertion order + // (which follows the dependency set's iteration order) to a sorted map. Omitting it makes + // consecutive builds of the same source differ, which in turn breaks the SHA-256 published + // for the launcher distribution. + repackager.repackage(targetFile, this::getLibraries, parseOutputTimestamp(outputTimestamp)); getLog().info("Successfully created self-executing JAR: " + targetFile); @@ -103,6 +116,16 @@ public void execute() throws MojoExecutionException, MojoFailureException { } } + /** + * Resolves the configured {@code project.build.outputTimestamp} into the last-modified time to stamp on the + * repackaged JAR, or {@code null} when reproducible output is not configured. + */ + static FileTime parseOutputTimestamp(String outputTimestamp) { + return MavenArchiver.parseBuildOutputTimestamp(outputTimestamp) + .map(FileTime::from) + .orElse(null); + } + private File getSourceJar() { if (sourceJar != null) { return sourceJar; diff --git a/tooling/maven/camel-repackager-maven-plugin/src/test/java/org/apache/camel/maven/RepackageMojoTest.java b/tooling/maven/camel-repackager-maven-plugin/src/test/java/org/apache/camel/maven/RepackageMojoTest.java index 8f2121d56dad6..a92017b6fd766 100644 --- a/tooling/maven/camel-repackager-maven-plugin/src/test/java/org/apache/camel/maven/RepackageMojoTest.java +++ b/tooling/maven/camel-repackager-maven-plugin/src/test/java/org/apache/camel/maven/RepackageMojoTest.java @@ -17,10 +17,23 @@ package org.apache.camel.maven; import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.stream.Collectors; import org.apache.maven.artifact.Artifact; import org.apache.maven.artifact.DefaultArtifact; import org.apache.maven.artifact.handler.DefaultArtifactHandler; +import org.apache.maven.project.MavenProject; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -31,38 +44,85 @@ */ public class RepackageMojoTest { + private static final String MAIN_CLASS = "org.apache.camel.dsl.jbang.launcher.CamelLauncher"; + private static final String LAUNCHER_CLASS_ENTRY = "org/apache/camel/dsl/jbang/launcher/CamelLauncher.class"; + private static final String DEPENDENCY_JAR_NAME = "camel-jbang-core-1.0.jar"; + private static final String FINAL_NAME = "camel-launcher-1.0"; + private static final String PINNED_TIMESTAMP = "2026-04-25T11:23:57Z"; + private static final long FIXED_ENTRY_TIME = 1000000000000L; + @TempDir File tempDir; + private int runCounter; + @Test public void testSpringBootLoaderStructure() throws Exception { - // This test would verify that the repackaged JAR has the correct Spring Boot structure - // For now, it's a placeholder to demonstrate the expected behavior - - // Expected structure after repackaging: - // - META-INF/MANIFEST.MF with Main-Class: org.springframework.boot.loader.launch.JarLauncher - // - org/springframework/boot/loader/ classes - // - BOOT-INF/classes/ with application classes - // - BOOT-INF/lib/ with dependency JARs + // The launcher is shipped as a single self-executing JAR, which only works if the repackaged + // artifact carries the loader classes at the root, the application classes under BOOT-INF/classes + // and the dependencies under BOOT-INF/lib. Losing any of the three yields a JAR that builds + // cleanly and then fails at 'java -jar' time. + File repackaged = repackage(PINNED_TIMESTAMP); + Set entries = entryNames(repackaged); - assertTrue(true, "Placeholder test - would verify Spring Boot JAR structure"); + assertTrue(entries.contains("BOOT-INF/classes/" + LAUNCHER_CLASS_ENTRY), + "application classes must be relocated under BOOT-INF/classes, but found: " + entries); + assertTrue(entries.contains("BOOT-INF/lib/" + DEPENDENCY_JAR_NAME), + "dependencies must be nested under BOOT-INF/lib, but found: " + entries); + assertTrue(entries.stream().anyMatch(e -> e.startsWith("org/springframework/boot/loader/")), + "the Spring Boot loader classes must sit at the JAR root so 'java -jar' can bootstrap"); } @Test public void testManifestEntries() throws Exception { - // This test would verify that the manifest has the correct entries: - // Main-Class: org.springframework.boot.loader.launch.JarLauncher - // Start-Class: org.apache.camel.dsl.jbang.launcher.CamelLauncher + // 'java -jar' dispatches through Main-Class, so it must name the loader, and the loader in turn + // reads Start-Class to find the real entry point. Swapping the two silently produces a JAR that + // cannot start. + File repackaged = repackage(PINNED_TIMESTAMP); - assertTrue(true, "Placeholder test - would verify manifest entries"); + try (JarFile jar = new JarFile(repackaged)) { + Attributes attributes = jar.getManifest().getMainAttributes(); + + assertEquals("org.springframework.boot.loader.launch.JarLauncher", attributes.getValue("Main-Class"), + "Main-Class must be the Spring Boot launcher, not the application entry point"); + assertEquals(MAIN_CLASS, attributes.getValue("Start-Class"), + "Start-Class must be the configured application entry point"); + } } @Test public void testDependencyInclusion() throws Exception { - // This test would verify that all compile and runtime dependencies - // are included as separate JARs in BOOT-INF/lib/ + // The filtering in includeArtifact only matters if it actually reaches the packaged output: a + // test-scoped jar must stay out of the distribution, and camel-exe:exe must never be embedded + // as a loader library (it is a native Windows binary staged by the assembly instead). + Artifact compileDependency = artifactWithFile("org.apache.camel", "camel-jbang-core", "jar", + Artifact.SCOPE_COMPILE); + Artifact testDependency = artifactWithFile("org.junit.jupiter", "junit-jupiter", "jar", Artifact.SCOPE_TEST); + Artifact nativeExe = artifactWithFile("org.apache.camel", "camel-exe", "exe", Artifact.SCOPE_COMPILE); + + File repackaged = repackage(PINNED_TIMESTAMP, compileDependency, testDependency, nativeExe); + Set libraries = entryNames(repackaged).stream() + .filter(e -> e.startsWith("BOOT-INF/lib/") && e.endsWith(".jar")) + .collect(Collectors.toSet()); - assertTrue(true, "Placeholder test - would verify dependency inclusion"); + assertTrue(libraries.contains("BOOT-INF/lib/" + DEPENDENCY_JAR_NAME), + "the compile-scoped jar must be nested as a loader library, but found: " + libraries); + assertFalse(libraries.stream().anyMatch(e -> e.contains("junit-jupiter")), + "a test-scoped dependency must not ship in the distribution, but found: " + libraries); + assertFalse(libraries.stream().anyMatch(e -> e.contains("camel-exe")), + "the native camel-exe bootstrap must not be embedded as a loader library, but found: " + libraries); + } + + @Test + public void testRepackagingIsReproducible() throws Exception { + // Two builds of the same source must produce byte-identical JARs. The launcher distribution's + // SHA-256 is published in the WinGet manifest and voted on during the release, so a JAR that + // differs run to run leaves a digest nobody can independently regenerate. + File first = repackage(PINNED_TIMESTAMP); + File second = repackage(PINNED_TIMESTAMP); + + assertArrayEquals(Files.readAllBytes(first.toPath()), Files.readAllBytes(second.toPath()), + "repackaging the same input twice must produce identical bytes"); } @Test @@ -104,7 +164,115 @@ public void testProvidedNonCamelJarIsExcluded() { "a provided-scope non-Camel jar dependency must not be bundled"); } + @Test + public void testIsoOutputTimestampIsParsed() { + // Passing a non-null time to the repackager is what makes the JAR reproducible: it pins every + // entry's modification time and switches BOOT-INF/lib to a sorted map. Without it, two builds + // of the same source produce different bytes, and the launcher archive's published SHA-256 + // cannot be regenerated by anyone verifying a release. + FileTime time = RepackageMojo.parseOutputTimestamp("2026-04-25T11:23:57Z"); + + assertNotNull(time, "an ISO-8601 project.build.outputTimestamp must yield a pinned time"); + assertEquals(Instant.parse("2026-04-25T11:23:57Z"), time.toInstant(), + "the pinned time must be the configured instant, not the build's wall-clock time"); + } + + @Test + public void testEpochSecondsOutputTimestampIsParsed() { + // Maven also accepts the SOURCE_DATE_EPOCH form: an integer of seconds since the epoch. + FileTime time = RepackageMojo.parseOutputTimestamp("1777029837"); + + assertNotNull(time, "a numeric project.build.outputTimestamp must yield a pinned time"); + assertEquals(Instant.ofEpochSecond(1777029837L), time.toInstant(), + "the pinned time must be the configured epoch second"); + } + + @Test + public void testUnsetOutputTimestampLeavesTimeUnpinned() { + // A single character is Maven's documented way to disable an inherited timestamp. Returning + // null keeps the previous behaviour rather than pinning entries to some arbitrary default. + assertNull(RepackageMojo.parseOutputTimestamp(null), + "an absent project.build.outputTimestamp must leave the build unpinned"); + assertNull(RepackageMojo.parseOutputTimestamp("-"), + "a single-character project.build.outputTimestamp disables reproducible output"); + } + + @Test + public void testInvalidOutputTimestampFailsLoudly() { + // A malformed value must not silently degrade to a non-reproducible build: the release would + // then publish a digest nobody can reproduce, and nothing would report why. + assertThrows(IllegalArgumentException.class, () -> RepackageMojo.parseOutputTimestamp("not-a-date"), + "an unparseable project.build.outputTimestamp must fail the build"); + } + private static Artifact artifact(String groupId, String artifactId, String type, String scope) { return new DefaultArtifact(groupId, artifactId, "1.0", scope, type, null, new DefaultArtifactHandler(type)); } + + /** + * Runs the mojo over a freshly staged source JAR, returning the repackaged result. Each call gets its own output + * directory so repeated invocations stay independent. + */ + private File repackage(String outputTimestamp, Artifact... dependencies) throws Exception { + File outputDirectory = new File(tempDir, "build-" + (runCounter++)); + assertTrue(outputDirectory.mkdirs(), "failed to create " + outputDirectory); + + File sourceJar = new File(outputDirectory, FINAL_NAME + ".jar"); + writeJar(sourceJar, LAUNCHER_CLASS_ENTRY); + + MavenProject project = new MavenProject(); + project.setArtifacts(Set.of(dependencies)); + + RepackageMojo mojo = new RepackageMojo(); + mojo.project = project; + mojo.outputDirectory = outputDirectory; + mojo.finalName = FINAL_NAME; + mojo.mainClass = MAIN_CLASS; + mojo.backupSource = false; + mojo.outputTimestamp = outputTimestamp; + mojo.execute(); + + return new File(outputDirectory, FINAL_NAME + ".jar"); + } + + private File repackage(String outputTimestamp) throws Exception { + return repackage(outputTimestamp, artifactWithFile("org.apache.camel", "camel-jbang-core", "jar", + Artifact.SCOPE_COMPILE)); + } + + /** + * Builds an artifact backed by a real JAR on disk. Spring Boot's packager only nests entries it can open as a zip, + * so the file has to be a genuine archive rather than a placeholder. + */ + private Artifact artifactWithFile(String groupId, String artifactId, String type, String scope) throws IOException { + Artifact artifact = artifact(groupId, artifactId, type, scope); + File file = new File(tempDir, artifactId + "-1.0." + type); + if (!file.exists()) { + writeJar(file, "org/apache/camel/Placeholder.class"); + } + artifact.setFile(file); + return artifact; + } + + /** + * Writes a minimal but valid JAR. Entry times are fixed so the fixture itself cannot introduce the very + * non-determinism {@link #testRepackagingIsReproducible()} is checking for. + */ + private static void writeJar(File file, String entryName) throws IOException { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(file.toPath()), manifest)) { + JarEntry entry = new JarEntry(entryName); + entry.setTime(FIXED_ENTRY_TIME); + out.putNextEntry(entry); + out.write(entryName.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + } + + private static Set entryNames(File jar) throws IOException { + try (JarFile jarFile = new JarFile(jar)) { + return jarFile.stream().map(JarEntry::getName).collect(Collectors.toSet()); + } + } }