From ee013b91015de7bfe2b8d18824eb6580e4cc54e8 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:15:41 -0400 Subject: [PATCH 01/23] CAMEL-23703: camel-launcher - JReleaser package generation for five packagers Add jreleaser.yml and camel-package.sh/.bat, driving Homebrew, SDKMAN, WinGet, Scoop, and Chocolatey distribution from a channel-plan + supported-LTS allowlist. Stages the website installer output (install.sh, install.ps1, release manifest) alongside the packages. Co-Authored-By: Claude Sonnet 5 --- dsl/camel-jbang/camel-launcher/README.md | 46 +- dsl/camel-jbang/camel-launcher/jreleaser.yml | 144 ++++++ dsl/camel-jbang/camel-launcher/pom.xml | 53 ++ .../src/jreleaser/bin/camel-package.bat | 257 +++++++++ .../src/jreleaser/bin/camel-package.sh | 337 ++++++++++++ .../camel-cli/brew/formula.rb.tpl | 73 +++ .../tools/chocolateyinstall.ps1.tpl | 39 ++ .../tools/chocolateyuninstall.ps1.tpl | 24 + .../camel-cli/scoop/manifest.json.tpl | 31 ++ .../camel-cli/sdkman/release-notes.md | 31 ++ .../camel-cli/winget/installer.yaml.tpl | 92 ++++ .../src/jreleaser/supported-lts.yml | 22 + .../dsl/jbang/launcher/PackagePlanTest.java | 486 ++++++++++++++++++ 13 files changed, 1633 insertions(+), 2 deletions(-) create mode 100644 dsl/camel-jbang/camel-launcher/jreleaser.yml create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat create mode 100755 dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyuninstall.ps1.tpl create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/installer.yaml.tpl create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java diff --git a/dsl/camel-jbang/camel-launcher/README.md b/dsl/camel-jbang/camel-launcher/README.md index f2d1bde1bd78b..86bf7cd3b7aab 100644 --- a/dsl/camel-jbang/camel-launcher/README.md +++ b/dsl/camel-jbang/camel-launcher/README.md @@ -25,7 +25,7 @@ that both files are staged and present in the assembled ZIP: 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 @@ -63,9 +63,11 @@ java -jar camel-launcher-.jar run hello.java # On Unix/Linux ./bin/camel.sh [command] [options] - # On Windows + # On Windows (x64) bin\camel.bat [command] [options] bin\camel-x64.exe [command] [options] + + # On Windows (arm64) bin\camel-arm64.exe [command] [options] ``` @@ -107,3 +109,43 @@ 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**; the other packagers +(SDKMAN, WinGet, Scoop, Chocolatey) use `repo1.maven.org` directly. + +### Native bootstrap executables + +The distribution 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). + +Chocolatey and Scoop use `camel.bat` as their entry point instead. Their custom templates +remove both exe files during install to avoid exposing unused executables on PATH. + +### Chocolatey ARM64 + +Native ARM64 support in Chocolatey is not yet available. Tracking issue: +https://github.com/chocolatey/choco/issues/1803 + +Until resolved, the Chocolatey package runs x64 via `camel.bat` on both x64 and ARM64 Windows +(ARM64 Windows transparently emulates x64 executables). diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml b/dsl/camel-jbang/camel-launcher/jreleaser.yml new file mode 100644 index 0000000000000..c111a689b8d0e --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -0,0 +1,144 @@ +# +# 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 verified against the JReleaser 1.25.0 +# reference (ctx7 docs, cross-checked with `mvn help:describe` and actual +# `jreleaser:prepare` dry-run output against the pinned plugin JAR) before +# authoring this file. Key findings that shaped this configuration: +# - `type: JAVA_BINARY` (not BINARY): Homebrew's automatic `openjdk` dependency is +# documented as applying to "applicable Java distributions"; confirmed the +# generated formula includes `depends_on "openjdk@17"`. +# - 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 at Phase 5 publish time. +# - `{{ 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: empirically (mvn jreleaser:config), +# 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. +# - Every packager overrides `downloadUrl` to the Maven Central coordinates +# (matching Phase 3A's installers and docs/getting-started.adoc); JReleaser's +# default download URL points at a GitHub release asset, which this project +# does not publish artifacts to. +# - `project.vendor` and `distributions.camel-cli.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 is deferred to a follow-up task; use +# a separate `--channel lts --lts-line X.Y` run to produce the versioned formula +# on its own in the meantime. +# +# JReleaser does not apply Homebrew's "AT" naming convention to `formulaName` +# itself: empirically confirmed (jreleaser:prepare with +# CAMEL_PKG_BREW_FORMULA=camel@4.20) that 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`/`camel-package.bat` +# work 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 + - url: https://raw.githubusercontent.com/apache/camel/main/docs/img/logo-medium-d.png + languages: + java: + groupId: org.apache.camel + version: 17 + +distributions: + camel-cli: + type: JAVA_BINARY + tags: + - cli + executable: + name: camel + # The zip/tar bin/ directory ships `camel.sh` (Unix) and `camel.bat`/`camel-x64.exe`/`camel-arm64.exe` + # (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 is deferred to Phase 5 publish. + winget: + active: RELEASE + downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + # Same basedir-resolution issue as brew (see the templateDirectory comment above): + # without this override, JReleaser silently ignores the module's installer.yaml.tpl. + templateDirectory: "dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget" + package: + identifier: Apache.CamelCLI + name: Camel CLI + 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" diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 993bff6f05c79..4edea2cf32733 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 @@ -329,6 +330,58 @@ + + + org.jreleaser + jreleaser-maven-plugin + ${jreleaser-plugin-version} + false + + ${project.basedir}/jreleaser.yml + true + + + + + com.mycila + license-maven-plugin + + + + + src/jreleaser/bin/camel-package.bat + src/jreleaser/distributions/camel-cli/brew/formula.rb.tpl + src/jreleaser/distributions/camel-cli/winget/installer.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 + + + + + diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat new file mode 100644 index 0000000000000..aa98ed924a841 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat @@ -0,0 +1,257 @@ +@echo off +@REM +@REM Licensed to the Apache Software Foundation (ASF) under one or more +@REM contributor license agreements. See the NOTICE file distributed with +@REM this work for additional information regarding copyright ownership. +@REM The ASF licenses this file to You under the Apache License, Version 2.0 +@REM (the "License"); you may not use this file except in compliance with +@REM the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM +setlocal enabledelayedexpansion +set "SCRIPT_DIR=%~dp0" +set "SUPPORTED_LTS=%SCRIPT_DIR%..\supported-lts.yml" + +set "SUBCOMMAND=%~1" +if "%SUBCOMMAND%"=="" goto usage +shift +if /I not "%SUBCOMMAND%"=="prepare" if /I not "%SUBCOMMAND%"=="publish" ( + echo Error: unknown subcommand '%SUBCOMMAND%'. 1>&2 + goto usage +) + +set "CHANNEL=" +set "LTS_LINE=" +set "PRINT_PLAN=0" +:parse +if "%~1"=="" goto parsed +if /I "%~1"=="--channel" ( set "CHANNEL=%~2" & shift & shift & goto parse ) +if /I "%~1"=="--lts-line" ( set "LTS_LINE=%~2" & shift & shift & goto parse ) +if /I "%~1"=="--print-plan" ( set "PRINT_PLAN=1" & shift & goto parse ) +echo Error: unknown argument '%~1'. 1>&2 +goto usage +:parsed + +if /I "%CHANNEL%"=="stable" goto channelOk +if /I "%CHANNEL%"=="lts" goto channelOk +echo Error: --channel must be 'stable' or 'lts' (got '%CHANNEL%'). 1>&2 +goto usage +:channelOk + +if /I "%CHANNEL%"=="lts" if "%LTS_LINE%"=="" ( + echo Error: --channel lts requires --lts-line X.Y. 1>&2 + goto usage +) + +if not "%LTS_LINE%"=="" ( + set "SUPPORT_ENDS=" + for /f "usebackq tokens=1,2,3" %%a in ("%SUPPORTED_LTS%") do ( + if "%%a"=="-" if "%%b"=="line:" ( set "CUR=%%c" & set "CUR=!CUR:"=!" ) + if "%%a"=="supportEnds:" if "!CUR!"=="%LTS_LINE%" ( set "SE=%%b" & set "SUPPORT_ENDS=!SE:"=!" ) + ) + if "!SUPPORT_ENDS!"=="" ( + echo Error: '%LTS_LINE%' is not a supported LTS line ^(see supported-lts.yml^). 1>&2 + exit /b 2 + ) + for /f "tokens=2 delims==" %%d in ('wmic os get localdatetime /format:list') do set "DT=%%d" + set "TODAY=!DT:~0,4!-!DT:~4,2!-!DT:~6,2!" + if "!TODAY!" GTR "!SUPPORT_ENDS!" ( + echo Error: LTS line '%LTS_LINE%' support ended on !SUPPORT_ENDS!. 1>&2 + exit /b 2 + ) +) + +if /I "%CHANNEL%"=="stable" ( + set "PACKAGERS=brew,sdkman,winget,scoop,chocolatey" + set "BREW_FORMULA=camel" + set "BREW_CLASS=Camel" + set "SDKMAN_DEFAULT=true" + set "WEBSITE_LATEST=true" + set "BREW_LTS_FORMULA=" + if not "%LTS_LINE%"=="" set "BREW_LTS_FORMULA=camel@%LTS_LINE%" +) else ( + REM Homebrew's own versioned-formula convention names the *file* "camel@X.Y.rb" but the + REM Ruby *class* "CamelATxy" (dot removed) - e.g. real homebrew-core "python@3.11.rb" + REM contains "class PythonAT311". JReleaser 1.25.0 does not apply this convention itself + REM (a literal formulaName "camel@4.20" renders invalid Ruby and a wrong output filename; + REM empirically confirmed). BREW_CLASS below is passed as formulaName instead, and + REM JReleaser's kebab-cased output file is renamed to the real "camel@X.Y.rb" after + REM packaging - see the rename step below the mvn invocation. + set "PACKAGERS=brew,sdkman,winget,chocolatey" + set "BREW_FORMULA=camel@%LTS_LINE%" + set "LTS_DIGITS=%LTS_LINE:.=%" + set "BREW_CLASS=CamelAT!LTS_DIGITS!" + set "SDKMAN_DEFAULT=false" + set "WEBSITE_LATEST=false" + set "BREW_LTS_FORMULA=" +) + +if "%PRINT_PLAN%"=="1" ( + 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! + if not "!BREW_LTS_FORMULA!"=="" echo BREW_LTS_FORMULA=!BREW_LTS_FORMULA! + if not "!BREW_FORMULA!"=="" echo BREW_FORMULA=!BREW_FORMULA! + if not "!BREW_CLASS!"=="" echo BREW_CLASS=!BREW_CLASS! + exit /b 0 +) + +if /I "%SUBCOMMAND%"=="publish" ( + echo Error: 'publish' is not yet implemented ^(Phase 5^). 1>&2 + exit /b 2 +) + +set "MODULE_DIR=%SCRIPT_DIR%..\..\.." + +@REM Resolve the release version. Production always reads Maven's project.version; tests/CI may +@REM override it, but only with both CAMEL_PACKAGE_TEST_MODE=true and CAMEL_PACKAGE_TEST_VERSION +@REM set, so production can never accidentally skip the real Maven version. +if not "%CAMEL_PACKAGE_TEST_VERSION%"=="" ( + if not "%CAMEL_PACKAGE_TEST_MODE%"=="true" ( + echo Error: CAMEL_PACKAGE_TEST_VERSION requires CAMEL_PACKAGE_TEST_MODE=true. 1>&2 + exit /b 2 + ) + set "PROJECT_VERSION=%CAMEL_PACKAGE_TEST_VERSION%" +) else ( + for /f "usebackq delims=" %%v in (`mvn -q -B -ntp -f "%MODULE_DIR%\pom.xml" org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout`) do set "PROJECT_VERSION=%%v" +) + +if "!PROJECT_VERSION:~-8!"=="SNAPSHOT" ( + echo Error: refusing to prepare packages for a snapshot version '!PROJECT_VERSION!'. 1>&2 + exit /b 2 +) + +set "TAR=%MODULE_DIR%\target\camel-launcher-!PROJECT_VERSION!-bin.tar.gz" +set "ZIP=%MODULE_DIR%\target\camel-launcher-!PROJECT_VERSION!-bin.zip" +set "INSTALL_SH_SRC=%MODULE_DIR%\src\install\install.sh" +set "INSTALL_PS1_SRC=%MODULE_DIR%\src\install\install.ps1" + +if not exist "!TAR!" ( + echo Error: release TAR not found: !TAR! 1>&2 + exit /b 1 +) +if not exist "!ZIP!" ( + echo Error: release ZIP not found: !ZIP! 1>&2 + exit /b 1 +) +if not exist "!INSTALL_SH_SRC!" ( + echo Error: installer source not found: !INSTALL_SH_SRC! 1>&2 + exit /b 1 +) +if not exist "!INSTALL_PS1_SRC!" ( + echo Error: installer source not found: !INSTALL_PS1_SRC! 1>&2 + exit /b 1 +) + +@REM Recreate only the prepared website staging directory (leave the rest of target\jreleaser alone). +set "WEBSITE_DIR=%MODULE_DIR%\target\jreleaser\website" +if exist "!WEBSITE_DIR!" rd /s /q "!WEBSITE_DIR!" +mkdir "!WEBSITE_DIR!" + +copy /y "!INSTALL_SH_SRC!" "!WEBSITE_DIR!\install.sh" >nul +if errorlevel 1 ( + echo Error: failed to copy install.sh. 1>&2 + exit /b 1 +) +copy /y "!INSTALL_PS1_SRC!" "!WEBSITE_DIR!\install.ps1" >nul +if errorlevel 1 ( + echo Error: failed to copy install.ps1. 1>&2 + exit /b 1 +) + +fc /b "!INSTALL_SH_SRC!" "!WEBSITE_DIR!\install.sh" >nul +if errorlevel 1 ( + echo Error: copied install.sh does not match its source. 1>&2 + exit /b 1 +) +fc /b "!INSTALL_PS1_SRC!" "!WEBSITE_DIR!\install.ps1" >nul +if errorlevel 1 ( + echo Error: copied install.ps1 does not match its source. 1>&2 + exit /b 1 +) + +call java "%MODULE_DIR%\src\jreleaser\java\WebsiteManifestGenerator.java" --version !PROJECT_VERSION! --tar "!TAR!" --zip "!ZIP!" --output "!WEBSITE_DIR!\camel-cli" --latest !WEBSITE_LATEST! +if errorlevel 1 ( + echo Error: website manifest generation failed. 1>&2 + exit /b 1 +) + +@REM jreleaser.yml reads the Homebrew formula name via `{{ Env.CAMEL_PKG_BREW_FORMULA }}`, +@REM which becomes the generated Ruby class name directly, so this must be BREW_CLASS (a +@REM valid Ruby class name), not the human-facing BREW_FORMULA. JReleaser's `Env.` template +@REM prefix resolves real OS environment variables, not Maven -D system properties, so this +@REM must be a real env var. Verified empirically with `jreleaser:prepare`. +set "CAMEL_PKG_BREW_FORMULA=!BREW_CLASS!" + +@REM Non-empty only for a versioned formula (e.g. "camel@4.20"); formula.rb.tpl uses +@REM this to add `keg_only :versioned_formula` and its PATH caveat. +set "CAMEL_PKG_BREW_VERSIONED=" +echo !BREW_FORMULA! | findstr /C:"@" >nul && set "CAMEL_PKG_BREW_VERSIONED=!BREW_FORMULA!" + +@REM `-Djreleaser.dry.run=true` below never performs a network call, so a placeholder +@REM satisfies JReleaser's config validation (it requires a non-blank release token) +@REM without requiring a real credential. Never overrides a token the caller already set. +if "%JRELEASER_GITHUB_TOKEN%"=="" set "JRELEASER_GITHUB_TOKEN=dry-run-placeholder" + +@REM `-Djreleaser.distributions` / `-Djreleaser.packagers` are the JReleaser Maven +@REM plugin's own include filters (confirmed via `mvn help:describe` on the pinned +@REM 1.25.0 plugin), used to select which packagers run for this channel; e.g. `lts` +@REM excludes `scoop` by omitting it from !PACKAGERS!. +@REM +@REM `-Djreleaser.project.version` is NOT a real parameter of this Mojo (confirmed via +@REM `mvn help:describe -Dgoal=prepare/config/package -Ddetail=true`: none of them expose it). +@REM JReleaser always reads the real Maven project.version from the POM itself, regardless of +@REM !PROJECT_VERSION! above (which only governs our own SNAPSHOT guard, artifact lookup, and the +@REM website manifest). What actually gates every packager above (`active: RELEASE`) is +@REM JReleaser's own snapshot detection, which matches the *real* POM version against +@REM `jreleaser.project.snapshot.pattern` (default `.*-SNAPSHOT`) - so on `main` (always SNAPSHOT) +@REM every packager would otherwise be silently skipped even once our own guard above has been +@REM satisfied via a CAMEL_PACKAGE_TEST_VERSION override. We override that pattern to something +@REM that can never match, so JReleaser treats the real POM version as a release too. This is a +@REM no-op for genuine non-snapshot releases (the pattern already wouldn't have matched). +@REM Empirically verified with a real (non-stubbed) jreleaser:prepare/package dry run. +echo Preparing packages for channel '%CHANNEL%' ^(packagers: !PACKAGERS!^)... +call mvn -B -ntp -f "%MODULE_DIR%\pom.xml" -Djreleaser.distributions=camel-cli -Djreleaser.packagers=!PACKAGERS! -Djreleaser.project.snapshot.pattern=CAMEL_LAUNCHER_NEVER_MATCH_SNAPSHOT_PATTERN jreleaser:config jreleaser:prepare jreleaser:package -Djreleaser.dry.run=true +if errorlevel 1 exit /b %ERRORLEVEL% + +@REM Homebrew's own versioned-formula convention names the *file* "camel@X.Y.rb" but +@REM JReleaser 1.25.0 derives the output filename from formulaName's literal text +@REM (kebab-casing our "CamelAT420" class name to "camel-at-420.rb"), not from Homebrew's +@REM file-naming rule. Empirically verified: formulaName "CamelAT420" produces exactly one +@REM file, Formula\camel-at-420.rb, whose *content* (class name) is already correct - only +@REM the filename needs fixing up here. +if /I "%CHANNEL%"=="lts" ( + set "BREW_FORMULA_DIR=%MODULE_DIR%\target\jreleaser\package\camel-cli\brew\Formula" + if exist "!BREW_FORMULA_DIR!" ( + set "GENERATED_FILE=" + set "MULTI_FOUND=0" + for %%f in ("!BREW_FORMULA_DIR!\*.rb") do ( + if not "!GENERATED_FILE!"=="" set "MULTI_FOUND=1" + set "GENERATED_FILE=%%f" + ) + if "!MULTI_FOUND!"=="1" ( + echo Error: expected exactly one generated Homebrew formula file in !BREW_FORMULA_DIR!, found multiple. 1>&2 + exit /b 1 + ) + if not "!GENERATED_FILE!"=="" if /I not "!GENERATED_FILE!"=="!BREW_FORMULA_DIR!\!BREW_FORMULA!.rb" ( + move /y "!GENERATED_FILE!" "!BREW_FORMULA_DIR!\!BREW_FORMULA!.rb" >nul + echo Renamed generated Homebrew formula to Homebrew's versioned-formula file convention: !BREW_FORMULA!.rb + ) + ) +) +exit /b 0 + +:usage +echo Usage: camel-package.bat ^ --channel ^ [--lts-line X.Y] [--print-plan] 1>&2 +exit /b 2 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..d635c4e53c2af --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -0,0 +1,337 @@ +#!/bin/sh +# +# 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 -eu + +SCRIPT_DIR=`CDPATH= cd -- "$(dirname -- "$0")" && pwd` +MODULE_DIR=`CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd` +SUPPORTED_LTS="$SCRIPT_DIR/../supported-lts.yml" + +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 + +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL="${2:-}"; shift 2 ;; + --lts-line) 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 + 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 [ "$today" \> "$supported_ends" ]; 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". JReleaser 1.25.0 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"); empirically confirmed. 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"; also empirically confirmed) + # 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 implemented in Phase 5. + echo "Error: 'publish' is not yet implemented (Phase 5)." 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 + +# 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" +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 "$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 + +# 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. Verified empirically with `jreleaser:prepare`. +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 on `main` (always SNAPSHOT) +# every packager would otherwise be silently skipped even once our own guard above has been +# satisfied via a CAMEL_PACKAGE_TEST_VERSION override. We override that pattern to something +# that can never match, so JReleaser treats the real POM version as a release too. This is a +# no-op for genuine non-snapshot releases (the pattern already wouldn't have matched). Empirically +# verified with a real (non-stubbed) `jreleaser:prepare`/`package` dry run. +echo "Preparing packages for channel '$CHANNEL' (packagers: $PACKAGERS)..." +mvn -B -ntp -f "$MODULE_DIR/pom.xml" \ + -Djreleaser.distributions=camel-cli \ + -Djreleaser.packagers="$PACKAGERS" \ + -Djreleaser.project.snapshot.pattern="CAMEL_LAUNCHER_NEVER_MATCH_SNAPSHOT_PATTERN" \ + jreleaser:config jreleaser:prepare jreleaser:package \ + -Djreleaser.dry.run=true + +# Homebrew's own versioned-formula convention names the *file* "camel@X.Y.rb" but +# JReleaser 1.25.0 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. Empirically verified (real jreleaser:package dry run): formulaName +# "CamelAT420" produces exactly one file, Formula/camel-at-420.rb, whose *content* (class +# name) is already correct - only the filename needs fixing up here. +if [ "$CHANNEL" = "lts" ]; then + brew_formula_dir="$MODULE_DIR/target/jreleaser/package/camel-cli/brew/Formula" + if [ -d "$brew_formula_dir" ]; then + 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 [ -n "$generated_file" ] && [ "$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 +fi + +# ---------------------------------------------------------------------------- +# TEST-MODE HACK: swap the Homebrew formula's download to a real, already-published +# camel-launcher release instead of this run's synthetic/SNAPSHOT-derived version. +# +# Every packager's downloadUrl (jreleaser.yml) points at the real Maven Central +# coordinates for {{projectVersion}}. That is correct in production - the version being +# released really is published there. In test mode, projectVersion is a synthetic +# placeholder (CAMEL_PACKAGE_TEST_VERSION) that is never actually published anywhere, so +# a real `brew install` always 404s at the download step. This was never exposed before +# because two earlier bugs (wrong output path, then Homebrew rejecting bare-file-path +# installs) failed before validation ever reached the download; both are now fixed, +# surfacing this as the next real blocker. Empirically confirmed on this machine. +# +# Rather than standing up a local HTTP mirror, we patch the *generated formula file* +# in place (never jreleaser.yml/formula.rb.tpl, which stay correct for real releases) so +# its url/version/sha256 describe a real, currently-published camel-launcher release. +# That lets `brew install`/`brew test` genuinely download, checksum-verify, install, and +# run a real artifact end-to-end in CI. camel-validate.sh's Homebrew validator reads the +# formula's own `version` line for its post-install assertion (rather than assuming the +# build's synthetic version), so it stays correct against whatever version this lands on. +# +# This intentionally does not exercise *this build's own* zip/tar.gz - camel-validate.sh's +# "local" archive check covers that separately, without going through any package manager. +if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ]; then + brew_formula_dir="$MODULE_DIR/target/jreleaser/package/camel-cli/brew/Formula" + formula_file="$brew_formula_dir/$BREW_FORMULA.rb" + if [ -f "$formula_file" ]; then + command -v curl >/dev/null 2>&1 || { echo "Error: curl is required to patch the test-mode Homebrew formula." 1>&2; exit 1; } + + known_good_version=$(curl -fsSL "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/maven-metadata.xml" \ + | sed -n 's/.*\(.*\)<\/release>.*/\1/p') + if [ -z "$known_good_version" ]; then + echo "Error: could not resolve a known-good camel-launcher release version from Maven Central." 1>&2 + exit 1 + fi + + known_good_url="https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/$known_good_version/camel-launcher-$known_good_version-bin.zip" + known_good_download=$(mktemp) + if ! curl -fsSL -o "$known_good_download" "$known_good_url"; then + rm -f "$known_good_download" + echo "Error: could not download known-good artifact at $known_good_url" 1>&2 + exit 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + known_good_sha256=$(sha256sum "$known_good_download" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + known_good_sha256=$(shasum -a 256 "$known_good_download" | awk '{print $1}') + else + rm -f "$known_good_download" + echo "Error: sha256sum or shasum is required to hash the known-good artifact." 1>&2 + exit 1 + fi + rm -f "$known_good_download" + + # Also patches the `test do` block's assert_match: JReleaser bakes {{projectVersion}} + # in there too (the real POM SNAPSHOT version, per the note above the mvn invocation - + # empirically confirmed: it was NOT the synthetic CAMEL_PACKAGE_TEST_VERSION either), + # so `brew test` would otherwise assert the wrong string against the real installed + # binary's actual `--version` output. + tmp_formula=$(mktemp) + sed -e "s#^\( url \).*#\1\"$known_good_url\"#" \ + -e "s#^\( version \).*#\1\"$known_good_version\"#" \ + -e "s#^\( sha256 \).*#\1\"$known_good_sha256\"#" \ + -e "s#assert_match \"[^\"]*\", output#assert_match \"$known_good_version\", output#" \ + "$formula_file" > "$tmp_formula" + mv "$tmp_formula" "$formula_file" + echo "TEST MODE: patched $formula_file to install real published camel-launcher $known_good_version (was synthetic $PROJECT_VERSION) - see the comment above this block for why." + 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..2035b3ecab805 --- /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..fdfb7a4b84852 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl @@ -0,0 +1,39 @@ +# +# 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 + +# Remove native bootstrap executables shipped in the distribution zip; Chocolatey +# uses camel.bat (shimmed via Install-BinFile below) and does not support +# per-architecture exe selection. Native ARM64 support in Chocolatey is pending: +# https://github.com/chocolatey/choco/issues/1803 +$bin = Join-Path $app_home 'bin' +Remove-Item (Join-Path $bin 'camel-x64.exe') -Force -ErrorAction SilentlyContinue +Remove-Item (Join-Path $bin 'camel-arm64.exe') -Force -ErrorAction SilentlyContinue + +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..54da1052c0fd2 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl @@ -0,0 +1,31 @@ +{ + "version": "{{projectVersion}}", + "description": "{{projectDescription}}", + "homepage": "{{projectLinkHomepage}}", + "license": "{{projectLicense}}", + "url": "{{distributionUrl}}", + "hash": "sha256:{{distributionChecksumSha256}}", + "extract_dir": "{{distributionArtifactRootEntryName}}", + "bin": "bin\\{{distributionExecutableWindows}}", + "post_install": [ + "Remove-Item \"$dir\\bin\\camel-x64.exe\" -Force -ErrorAction SilentlyContinue", + "Remove-Item \"$dir\\bin\\camel-arm64.exe\" -Force -ErrorAction SilentlyContinue" + ], + "suggest": { + "JDK": [ + "java/oraclejdk", + "java/openjdk" + ] + }, + "checkver": { + "url": "{{scoopCheckverUrl}}", + "re": "v([\\d.]+).zip" + }, + "autoupdate": { + "url": "{{scoopAutoupdateUrl}}", + "extract_dir": "{{scoopAutoupdateExtractDir}}", + "hash": { + "url": "$url.sha256" + } + } +} 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..b712776ef06b4 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/sdkman/release-notes.md @@ -0,0 +1,31 @@ + + + + +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..c0eaf18ff8e80 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/installer.yaml.tpl @@ -0,0 +1,92 @@ +# +# 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.9.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 camel-launcher distribution zip ships both camel-x64.exe and camel-arm64.exe in +# bin/ (see src/main/assembly/bin.xml), so each architecture entry selects the correct +# native exe via NestedInstallerFiles/RelativeFilePath. + +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.9.0 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..f05a871995339 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/supported-lts.yml @@ -0,0 +1,22 @@ +# +# 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.18" + supportEnds: "2026-12-31" + - line: "4.22" + supportEnds: "2027-09-30" 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..d997db22a1928 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java @@ -0,0 +1,486 @@ +/* + * 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.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +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.assertTrue; + +/** + * Verifies the channel -> packaging-plan mapping, LTS validation, and website-staging logic in camel-package.sh (POSIX) + * and camel-package.bat (Windows). Platform-specific process launching lives in the nested classes; shared fixture + * helpers and assertions live in the enclosing class. + */ +class PackagePlanTest { + + static final Path MODULE_DIR = Paths.get("").toAbsolutePath(); + static final String TEST_VERSION = "9.9.9"; + + 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 websiteDir() { + return MODULE_DIR.resolve("target/jreleaser/website"); + } + + @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")); + deleteRecursively(websiteDir()); + } + + 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))); + } + + // ── POSIX (camel-package.sh) ────────────────────────────────────── + + @Nested + @DisabledOnOs(OS.WINDOWS) + class Posix { + + 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("/bin/sh"); + cmd.add(SCRIPT.toString()); + for (String a : args) { + cmd.add(a); + } + 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)); + + Map env = new LinkedHashMap<>(); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); + 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("prepare", "--channel", "stable", "--lts-line", "4.18", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("CHANNEL=stable"), r.stdout); + assertTrue(r.stdout.contains("LTS_LINE=4.18"), r.stdout); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,scoop,chocolatey"), r.stdout); + assertTrue(r.stdout.contains("BREW_LTS_FORMULA=camel@4.18"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=true"), r.stdout); + } + + @Test + void ltsSelectsFourPackagersExcludingScoopWithSdkmanNotDefault() throws Exception { + Result r = run("prepare", "--channel", "lts", "--lts-line", "4.18", "--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.18"), + "LTS produces a versioned brew formula: " + r.stdout); + assertTrue(r.stdout.contains("BREW_CLASS=CamelAT418"), + "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 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); + } + + @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"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "lts", "--lts-line", "4.18"); + + 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 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)); + } + } + + // ── Windows (camel-package.bat) ─────────────────────────────────── + + @Nested + @EnabledOnOs(OS.WINDOWS) + class Windows { + + private static final Path SCRIPT = Paths.get("src/jreleaser/bin/camel-package.bat"); + + 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("cmd.exe"); + cmd.add("/c"); + cmd.add(SCRIPT.toString()); + for (String a : args) { + cmd.add(a); + } + 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.cmd"); + Files.writeString(mvnStub, "@echo off\r\necho %* >> \"" + recordFile + "\"\r\nexit /b 0\r\n", + StandardCharsets.UTF_8); + + Map env = new LinkedHashMap<>(); + env.put("CAMEL_PACKAGE_TEST_MODE", "true"); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); + 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("PACKAGERS=brew,sdkman,winget,scoop,chocolatey"), r.stdout); + assertTrue(r.stdout.contains("BREW_CLASS=Camel"), r.stdout); + assertTrue(r.stdout.contains("SDKMAN_DEFAULT=true"), r.stdout); + assertTrue(r.stdout.contains("WEBSITE_LATEST=true"), r.stdout); + assertFalse(r.stdout.contains("BREW_LTS_FORMULA="), r.stdout); + } + + @Test + void ltsExcludesScoopWithSdkmanNotDefault() throws Exception { + Result r = run("prepare", "--channel", "lts", "--lts-line", "4.18", "--print-plan"); + + assertEquals(0, r.exit, r.stderr); + assertTrue(r.stdout.contains("PACKAGERS=brew,sdkman,winget,chocolatey"), r.stdout); + assertFalse(r.stdout.contains("scoop"), r.stdout); + assertTrue(r.stdout.contains("BREW_FORMULA=camel@4.18"), + "LTS produces a versioned brew formula: " + r.stdout); + assertTrue(r.stdout.contains("BREW_CLASS=CamelAT418"), + "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_LATEST=false"), r.stdout); + } + + @Test + void rejectsUnsupportedLtsLine() throws Exception { + Result r = run("prepare", "--channel", "lts", "--lts-line", "3.14", "--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); + } + + @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"); + Map env = testModeEnvWithMvnStub(tmp, recordFile); + + Result r = run(env, "prepare", "--channel", "lts", "--lts-line", "4.18"); + + 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 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"); + assertFalse(Files.exists(recordFile)); + } + } +} From 61235379752196382d3137005df3abd3e8a50659 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:05:26 -0400 Subject: [PATCH 02/23] CAMEL-23703: camel-launcher - fix critical JReleaser packaging review findings - Remove stale supported-lts.yml "4.18" entry (package-manager LTS support starts at 4.22) - Add CAMEL_PACKAGE_TEST_SUPPORTED_LTS test override so LTS-expiry tests are decoupled from real production supportEnds dates (mirrors CAMEL_PACKAGE_TEST_VERSION), and cover the previously-untested "line found but expired" rejection branch - Fail loudly instead of silently succeeding when the LTS Homebrew-formula rename step finds zero generated .rb files (sh and bat) - Replace deprecated wmic date lookup in camel-package.bat with PowerShell, and hard-fail if the resolved date is empty/malformed instead of silently skipping the LTS expiry check - Correct jreleaser.yml NOTE and README.md docs that no longer matched shipped behavior (unpinned openjdk dependency, ARM64 Chocolatey handling, Windows camel.bat usage) - Gate the Homebrew test-mode formula patch (and the JReleaser snapshot-pattern override) on CAMEL_PACKAGE_TEST_VERSION in addition to CAMEL_PACKAGE_TEST_MODE, so a genuine release run can never silently repoint the generated formula at a different already-published version, and JReleaser keeps its own snapshot guard on real releases - camel-package.bat: fail loudly when `mvn evaluate` yields no project.version (reset the variable, then check errorlevel and empty), and when the formula rename `move` fails, matching the sh script's set -e behavior - Distinguish a missing/malformed supported-lts.yml from an unknown LTS line in both wrappers, with a dedicated test - Chocolatey/Scoop native-exe removal: use -ErrorAction Stop behind a Test-Path guard instead of -ErrorAction SilentlyContinue, so a failed removal fails loudly rather than leaving a stray exe shimmed on PATH - Scoop manifest checkver/autoupdate: point at Maven Central metadata and the real artifact name instead of unset JReleaser template placeholders - Note in camel-package.bat that the Homebrew test-mode patch is intentionally POSIX-only (Homebrew validation runs on macOS/Linux) - Soften "empirically confirmed"/version-pinned comments to reference the pinned JReleaser plugin version, and fix the openjdk-dependency rationale to reflect that formula.rb.tpl declares depends_on explicitly - Add tests covering the LTS Homebrew formula rename, the test-mode formula-patch gate, malformed LTS metadata, the documented 4.22 support window, and stronger assertions on the JReleaser goal/packager/snapshot-pattern invocation Co-Authored-By: Claude Sonnet 5 --- dsl/camel-jbang/camel-launcher/README.md | 10 +- dsl/camel-jbang/camel-launcher/jreleaser.yml | 39 ++-- dsl/camel-jbang/camel-launcher/pom.xml | 16 +- .../src/jreleaser/bin/camel-package.bat | 78 +++++-- .../src/jreleaser/bin/camel-package.sh | 69 +++++-- .../tools/chocolateyinstall.ps1.tpl | 8 +- .../camel-cli/scoop/manifest.json.tpl | 15 +- .../camel-cli/sdkman/release-notes.md | 5 +- .../src/jreleaser/supported-lts.yml | 2 - .../dsl/jbang/launcher/PackagePlanTest.java | 195 +++++++++++++++++- .../resources/supported-lts-test-fixture.yml | 24 +++ 11 files changed, 364 insertions(+), 97 deletions(-) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/resources/supported-lts-test-fixture.yml diff --git a/dsl/camel-jbang/camel-launcher/README.md b/dsl/camel-jbang/camel-launcher/README.md index 86bf7cd3b7aab..23a32cc006535 100644 --- a/dsl/camel-jbang/camel-launcher/README.md +++ b/dsl/camel-jbang/camel-launcher/README.md @@ -63,8 +63,10 @@ java -jar camel-launcher-.jar run hello.java # On Unix/Linux ./bin/camel.sh [command] [options] - # On Windows (x64) + # On Windows (x64 or arm64) bin\camel.bat [command] [options] + + # On Windows (x64) bin\camel-x64.exe [command] [options] # On Windows (arm64) @@ -147,5 +149,7 @@ remove both exe files during install to avoid exposing unused executables on PAT Native ARM64 support in Chocolatey is not yet available. Tracking issue: https://github.com/chocolatey/choco/issues/1803 -Until resolved, the Chocolatey package runs x64 via `camel.bat` on both x64 and ARM64 Windows -(ARM64 Windows transparently emulates x64 executables). +Until resolved, the Chocolatey package uses `camel.bat` as its entry point on both x64 and ARM64 +Windows. `camel.bat` is architecture-neutral (it only needs a Java runtime, not a native +executable), so this works without emulation; it just means Chocolatey isn't yet shipping a +genuine per-architecture executable the way WinGet does. diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml b/dsl/camel-jbang/camel-launcher/jreleaser.yml index c111a689b8d0e..dd6b4457e32e3 100644 --- a/dsl/camel-jbang/camel-launcher/jreleaser.yml +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -15,13 +15,16 @@ # limitations under the License. # -# NOTE: field names and packager semantics verified against the JReleaser 1.25.0 -# reference (ctx7 docs, cross-checked with `mvn help:describe` and actual -# `jreleaser:prepare` dry-run output against the pinned plugin JAR) before -# authoring this file. Key findings that shaped this configuration: -# - `type: JAVA_BINARY` (not BINARY): Homebrew's automatic `openjdk` dependency is -# documented as applying to "applicable Java distributions"; confirmed the -# generated formula includes `depends_on "openjdk@17"`. +# 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 @@ -30,9 +33,9 @@ # 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: empirically (mvn jreleaser:config), -# the JReleaser Maven plugin's basedir defaults to the multi-module reactor root, -# not this file's directory, unlike the standalone JReleaser CLI. +# - 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. @@ -53,15 +56,13 @@ # a separate `--channel lts --lts-line X.Y` run to produce the versioned formula # on its own in the meantime. # -# JReleaser does not apply Homebrew's "AT" naming convention to `formulaName` -# itself: empirically confirmed (jreleaser:prepare with -# CAMEL_PKG_BREW_FORMULA=camel@4.20) that 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`/`camel-package.bat` -# work 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. +# 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`/`camel-package.bat` work 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 diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 4edea2cf32733..f02c4a272d960 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -353,16 +353,12 @@ already carries a correct, manually-verified header, so it is excluded here rather than reordering `@echo off` out of the required first line. - None of the distribution templates have a recognized comment-style mapping for - their compound extension (license:format only inspects the final extension, - ".tpl", which is unmapped), so they fail outright rather than being reformatted. - formula.rb.tpl, installer.yaml.tpl, and both chocolatey*.ps1.tpl files already - carry a manually verified, language-appropriate (#) header, so they are excluded - here instead of adding a repo-wide ".tpl" mapping that would assume a single - comment style for every future template of any language. manifest.json.tpl is - excluded for a different reason: JSON has no comment syntax at all, so no header - can be embedded; the repo-wide "**/*.json" exclude in the root pom does not reach - it because its final extension is ".tpl", not ".json". + The distribution templates all end in ".tpl", which license-maven-plugin does + not map to a comment style when failIfUnknown is enabled. They are excluded here + instead of adding a repo-wide ".tpl" mapping that would assume a single comment + style for templates of different languages. The JSON template is included in the + same exclude set because the root "**/*.json" exclude does not reach a file whose + final extension is ".tpl". --> com.mycila diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat index aa98ed924a841..16974d59e9c62 100644 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.bat @@ -19,6 +19,16 @@ setlocal enabledelayedexpansion set "SCRIPT_DIR=%~dp0" set "SUPPORTED_LTS=%SCRIPT_DIR%..\supported-lts.yml" +@REM Tests may point this at a synthetic LTS allowlist so expiry-date assertions don't ride on +@REM real production supportEnds dates (mirrors CAMEL_PACKAGE_TEST_VERSION further below). +if not "%CAMEL_PACKAGE_TEST_SUPPORTED_LTS%"=="" ( + if not "%CAMEL_PACKAGE_TEST_MODE%"=="true" ( + echo Error: CAMEL_PACKAGE_TEST_SUPPORTED_LTS requires CAMEL_PACKAGE_TEST_MODE=true. 1>&2 + exit /b 2 + ) + set "SUPPORTED_LTS=%CAMEL_PACKAGE_TEST_SUPPORTED_LTS%" +) + set "SUBCOMMAND=%~1" if "%SUBCOMMAND%"=="" goto usage shift @@ -51,6 +61,15 @@ if /I "%CHANNEL%"=="lts" if "%LTS_LINE%"=="" ( ) if not "%LTS_LINE%"=="" ( + if not exist "%SUPPORTED_LTS%" ( + echo Error: supported LTS metadata is not readable: %SUPPORTED_LTS% 1>&2 + exit /b 1 + ) + findstr /r /c:"^[ ][ ]*-[ ][ ]*line:" /c:"^-[ ][ ]*line:" "%SUPPORTED_LTS%" >nul + if errorlevel 1 ( + echo Error: supported LTS metadata is malformed: %SUPPORTED_LTS% 1>&2 + exit /b 1 + ) set "SUPPORT_ENDS=" for /f "usebackq tokens=1,2,3" %%a in ("%SUPPORTED_LTS%") do ( if "%%a"=="-" if "%%b"=="line:" ( set "CUR=%%c" & set "CUR=!CUR:"=!" ) @@ -60,8 +79,16 @@ if not "%LTS_LINE%"=="" ( echo Error: '%LTS_LINE%' is not a supported LTS line ^(see supported-lts.yml^). 1>&2 exit /b 2 ) - for /f "tokens=2 delims==" %%d in ('wmic os get localdatetime /format:list') do set "DT=%%d" - set "TODAY=!DT:~0,4!-!DT:~4,2!-!DT:~6,2!" + REM wmic is deprecated/removed on newer Windows (24H2+); a missing wmic previously left TODAY + REM empty and silently skipped this check instead of failing. PowerShell is present on every + REM supported Windows version, and the findstr guard below fails loudly if it ever isn't. + set "TODAY=" + for /f "usebackq delims=" %%d in (`powershell -NoProfile -Command "(Get-Date).ToString('yyyy-MM-dd')"`) do set "TODAY=%%d" + echo !TODAY!| findstr /r "^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$" >nul + if errorlevel 1 ( + echo Error: could not resolve today's date via PowerShell ^(got '!TODAY!'^). 1>&2 + exit /b 1 + ) if "!TODAY!" GTR "!SUPPORT_ENDS!" ( echo Error: LTS line '%LTS_LINE%' support ended on !SUPPORT_ENDS!. 1>&2 exit /b 2 @@ -79,9 +106,10 @@ if /I "%CHANNEL%"=="stable" ( ) else ( REM Homebrew's own versioned-formula convention names the *file* "camel@X.Y.rb" but the REM Ruby *class* "CamelATxy" (dot removed) - e.g. real homebrew-core "python@3.11.rb" - REM contains "class PythonAT311". JReleaser 1.25.0 does not apply this convention itself + REM contains "class PythonAT311". As of the pinned JReleaser plugin version in pom.xml, + REM JReleaser does not apply this convention itself REM (a literal formulaName "camel@4.20" renders invalid Ruby and a wrong output filename; - REM empirically confirmed). BREW_CLASS below is passed as formulaName instead, and + REM BREW_CLASS below is passed as formulaName instead, and REM JReleaser's kebab-cased output file is renamed to the real "camel@X.Y.rb" after REM packaging - see the rename step below the mvn invocation. set "PACKAGERS=brew,sdkman,winget,chocolatey" @@ -124,7 +152,16 @@ if not "%CAMEL_PACKAGE_TEST_VERSION%"=="" ( ) set "PROJECT_VERSION=%CAMEL_PACKAGE_TEST_VERSION%" ) else ( + set "PROJECT_VERSION=" for /f "usebackq delims=" %%v in (`mvn -q -B -ntp -f "%MODULE_DIR%\pom.xml" org.apache.maven.plugins:maven-help-plugin:3.5.1:evaluate -Dexpression=project.version -DforceStdout`) do set "PROJECT_VERSION=%%v" + if errorlevel 1 ( + echo Error: could not resolve project.version via Maven. 1>&2 + exit /b 1 + ) + if "!PROJECT_VERSION!"=="" ( + echo Error: could not resolve project.version via Maven. 1>&2 + exit /b 1 + ) ) if "!PROJECT_VERSION:~-8!"=="SNAPSHOT" ( @@ -191,7 +228,7 @@ if errorlevel 1 ( @REM which becomes the generated Ruby class name directly, so this must be BREW_CLASS (a @REM valid Ruby class name), not the human-facing BREW_FORMULA. JReleaser's `Env.` template @REM prefix resolves real OS environment variables, not Maven -D system properties, so this -@REM must be a real env var. Verified empirically with `jreleaser:prepare`. +@REM must be a real env var. set "CAMEL_PKG_BREW_FORMULA=!BREW_CLASS!" @REM Non-empty only for a versioned formula (e.g. "camel@4.20"); formula.rb.tpl uses @@ -215,21 +252,20 @@ if "%JRELEASER_GITHUB_TOKEN%"=="" set "JRELEASER_GITHUB_TOKEN=dry-run-placeholde @REM !PROJECT_VERSION! above (which only governs our own SNAPSHOT guard, artifact lookup, and the @REM website manifest). What actually gates every packager above (`active: RELEASE`) is @REM JReleaser's own snapshot detection, which matches the *real* POM version against -@REM `jreleaser.project.snapshot.pattern` (default `.*-SNAPSHOT`) - so on `main` (always SNAPSHOT) -@REM every packager would otherwise be silently skipped even once our own guard above has been -@REM satisfied via a CAMEL_PACKAGE_TEST_VERSION override. We override that pattern to something -@REM that can never match, so JReleaser treats the real POM version as a release too. This is a -@REM no-op for genuine non-snapshot releases (the pattern already wouldn't have matched). -@REM Empirically verified with a real (non-stubbed) jreleaser:prepare/package dry run. +@REM `jreleaser.project.snapshot.pattern` (default `.*-SNAPSHOT`) - so test-mode runs from +@REM a SNAPSHOT checkout would otherwise skip every packager even once our own guard has been +@REM satisfied via a CAMEL_PACKAGE_TEST_VERSION override. In that one case, override the pattern +@REM to something that can never match, so JReleaser treats the real POM version as a release too. +set "SNAPSHOT_PATTERN_ARG=" +if "%CAMEL_PACKAGE_TEST_MODE%"=="true" if not "%CAMEL_PACKAGE_TEST_VERSION%"=="" set "SNAPSHOT_PATTERN_ARG=-Djreleaser.project.snapshot.pattern=CAMEL_LAUNCHER_NEVER_MATCH_SNAPSHOT_PATTERN" echo Preparing packages for channel '%CHANNEL%' ^(packagers: !PACKAGERS!^)... -call mvn -B -ntp -f "%MODULE_DIR%\pom.xml" -Djreleaser.distributions=camel-cli -Djreleaser.packagers=!PACKAGERS! -Djreleaser.project.snapshot.pattern=CAMEL_LAUNCHER_NEVER_MATCH_SNAPSHOT_PATTERN jreleaser:config jreleaser:prepare jreleaser:package -Djreleaser.dry.run=true +call mvn -B -ntp -f "%MODULE_DIR%\pom.xml" -Djreleaser.distributions=camel-cli -Djreleaser.packagers=!PACKAGERS! !SNAPSHOT_PATTERN_ARG! jreleaser:config jreleaser:prepare jreleaser:package -Djreleaser.dry.run=true if errorlevel 1 exit /b %ERRORLEVEL% @REM Homebrew's own versioned-formula convention names the *file* "camel@X.Y.rb" but -@REM JReleaser 1.25.0 derives the output filename from formulaName's literal text +@REM the pinned JReleaser plugin derives the output filename from formulaName's literal text @REM (kebab-casing our "CamelAT420" class name to "camel-at-420.rb"), not from Homebrew's -@REM file-naming rule. Empirically verified: formulaName "CamelAT420" produces exactly one -@REM file, Formula\camel-at-420.rb, whose *content* (class name) is already correct - only +@REM file-naming rule. The generated formula content already has the correct class name, so only @REM the filename needs fixing up here. if /I "%CHANNEL%"=="lts" ( set "BREW_FORMULA_DIR=%MODULE_DIR%\target\jreleaser\package\camel-cli\brew\Formula" @@ -244,12 +280,22 @@ if /I "%CHANNEL%"=="lts" ( echo Error: expected exactly one generated Homebrew formula file in !BREW_FORMULA_DIR!, found multiple. 1>&2 exit /b 1 ) - if not "!GENERATED_FILE!"=="" if /I not "!GENERATED_FILE!"=="!BREW_FORMULA_DIR!\!BREW_FORMULA!.rb" ( + if "!GENERATED_FILE!"=="" ( + echo Error: expected exactly one generated Homebrew formula file in !BREW_FORMULA_DIR!, found none. 1>&2 + exit /b 1 + ) + if /I not "!GENERATED_FILE!"=="!BREW_FORMULA_DIR!\!BREW_FORMULA!.rb" ( move /y "!GENERATED_FILE!" "!BREW_FORMULA_DIR!\!BREW_FORMULA!.rb" >nul + if errorlevel 1 ( + echo Error: failed to rename generated Homebrew formula to !BREW_FORMULA!.rb. 1>&2 + exit /b 1 + ) echo Renamed generated Homebrew formula to Homebrew's versioned-formula file convention: !BREW_FORMULA!.rb ) ) ) +@REM The POSIX wrapper has a Homebrew-only test-mode patch after packaging. That patch is not +@REM duplicated here because Homebrew package validation runs on macOS/Linux, not Windows. exit /b 0 :usage diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh index d635c4e53c2af..2957956a8b642 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -22,6 +22,16 @@ 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 @@ -60,6 +70,14 @@ 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) } @@ -88,11 +106,12 @@ if [ "$CHANNEL" = "stable" ]; then 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". JReleaser 1.25.0 does not apply this convention itself: + # 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"); empirically confirmed. So BREW_CLASS below is + # 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"; also empirically confirmed) + # 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" @@ -199,7 +218,7 @@ fi # 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. Verified empirically with `jreleaser:prepare`. +# 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 @@ -227,26 +246,27 @@ export JRELEASER_GITHUB_TOKEN # $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 on `main` (always SNAPSHOT) -# every packager would otherwise be silently skipped even once our own guard above has been -# satisfied via a CAMEL_PACKAGE_TEST_VERSION override. We override that pattern to something -# that can never match, so JReleaser treats the real POM version as a release too. This is a -# no-op for genuine non-snapshot releases (the pattern already wouldn't have matched). Empirically -# verified with a real (non-stubbed) `jreleaser:prepare`/`package` dry run. +# `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_ARG="" +if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ] && [ -n "${CAMEL_PACKAGE_TEST_VERSION:-}" ]; then + SNAPSHOT_PATTERN_ARG='-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 \ -Djreleaser.packagers="$PACKAGERS" \ - -Djreleaser.project.snapshot.pattern="CAMEL_LAUNCHER_NEVER_MATCH_SNAPSHOT_PATTERN" \ + $SNAPSHOT_PATTERN_ARG \ jreleaser:config jreleaser:prepare jreleaser:package \ -Djreleaser.dry.run=true # Homebrew's own versioned-formula convention names the *file* "camel@X.Y.rb" but -# JReleaser 1.25.0 derives the output filename from formulaName's literal text +# 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. Empirically verified (real jreleaser:package dry run): formulaName -# "CamelAT420" produces exactly one file, Formula/camel-at-420.rb, whose *content* (class -# name) is already correct - only the filename needs fixing up here. +# 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" if [ -d "$brew_formula_dir" ]; then @@ -259,7 +279,11 @@ if [ "$CHANNEL" = "lts" ]; then fi generated_file="$f" done - if [ -n "$generated_file" ] && [ "$generated_file" != "$brew_formula_dir/$BREW_FORMULA.rb" ]; then + 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 @@ -275,9 +299,7 @@ fi # released really is published there. In test mode, projectVersion is a synthetic # placeholder (CAMEL_PACKAGE_TEST_VERSION) that is never actually published anywhere, so # a real `brew install` always 404s at the download step. This was never exposed before -# because two earlier bugs (wrong output path, then Homebrew rejecting bare-file-path -# installs) failed before validation ever reached the download; both are now fixed, -# surfacing this as the next real blocker. Empirically confirmed on this machine. +# because earlier Homebrew validation failures happened before validation reached the download. # # Rather than standing up a local HTTP mirror, we patch the *generated formula file* # in place (never jreleaser.yml/formula.rb.tpl, which stay correct for real releases) so @@ -289,7 +311,7 @@ fi # # This intentionally does not exercise *this build's own* zip/tar.gz - camel-validate.sh's # "local" archive check covers that separately, without going through any package manager. -if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ]; then +if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ] && [ -n "${CAMEL_PACKAGE_TEST_VERSION:-}" ]; then brew_formula_dir="$MODULE_DIR/target/jreleaser/package/camel-cli/brew/Formula" formula_file="$brew_formula_dir/$BREW_FORMULA.rb" if [ -f "$formula_file" ]; then @@ -319,10 +341,13 @@ if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ]; then exit 1 fi rm -f "$known_good_download" + if [ -z "$known_good_sha256" ]; then + echo "Error: could not compute sha256 for the known-good artifact." 1>&2 + exit 1 + fi # Also patches the `test do` block's assert_match: JReleaser bakes {{projectVersion}} - # in there too (the real POM SNAPSHOT version, per the note above the mvn invocation - - # empirically confirmed: it was NOT the synthetic CAMEL_PACKAGE_TEST_VERSION either), + # in there too (the real POM SNAPSHOT version, per the note above the mvn invocation), # so `brew test` would otherwise assert the wrong string against the real installed # binary's actual `--version` output. tmp_formula=$(mktemp) 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 index fdfb7a4b84852..d3022b94689f9 100644 --- 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 @@ -33,7 +33,11 @@ Install-ChocolateyZipPackage ` # per-architecture exe selection. Native ARM64 support in Chocolatey is pending: # https://github.com/chocolatey/choco/issues/1803 $bin = Join-Path $app_home 'bin' -Remove-Item (Join-Path $bin 'camel-x64.exe') -Force -ErrorAction SilentlyContinue -Remove-Item (Join-Path $bin 'camel-arm64.exe') -Force -ErrorAction SilentlyContinue +foreach ($native_exe in @('camel-x64.exe', 'camel-arm64.exe')) { + $native_path = Join-Path $bin $native_exe + if (Test-Path -LiteralPath $native_path) { + Remove-Item -LiteralPath $native_path -Force -ErrorAction Stop + } +} Install-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 index 54da1052c0fd2..005b285c3752b 100644 --- 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 @@ -7,10 +7,7 @@ "hash": "sha256:{{distributionChecksumSha256}}", "extract_dir": "{{distributionArtifactRootEntryName}}", "bin": "bin\\{{distributionExecutableWindows}}", - "post_install": [ - "Remove-Item \"$dir\\bin\\camel-x64.exe\" -Force -ErrorAction SilentlyContinue", - "Remove-Item \"$dir\\bin\\camel-arm64.exe\" -Force -ErrorAction SilentlyContinue" - ], + "post_install": "foreach ($native_exe in @('camel-x64.exe', 'camel-arm64.exe')) { $native_path = Join-Path \"$dir\\bin\" $native_exe; if (Test-Path -LiteralPath $native_path) { Remove-Item -LiteralPath $native_path -Force -ErrorAction Stop } }", "suggest": { "JDK": [ "java/oraclejdk", @@ -18,14 +15,14 @@ ] }, "checkver": { - "url": "{{scoopCheckverUrl}}", - "re": "v([\\d.]+).zip" + "url": "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/maven-metadata.xml", + "regex": "([\\d.]+)" }, "autoupdate": { - "url": "{{scoopAutoupdateUrl}}", - "extract_dir": "{{scoopAutoupdateExtractDir}}", + "url": "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/$version/camel-launcher-$version-bin.zip", + "extract_dir": "camel-launcher-$version", "hash": { - "url": "$url.sha256" + "url": "$url.sha512" } } } 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 index b712776ef06b4..4927c4f848fa0 100644 --- 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 @@ -16,9 +16,8 @@ --> org.jreleaser @@ -347,12 +347,6 @@ include-camel-exe @@ -406,6 +406,24 @@ + + org.apache.maven.plugins + maven-assembly-plugin + + + assemble-winget-bin + package + + single + + + + 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 index 1a422aad4801a..d7ac095349b3e 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -250,15 +250,15 @@ export JRELEASER_GITHUB_TOKEN # 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_ARG="" +SNAPSHOT_PATTERN_ARGS=() if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ] && [ -n "${CAMEL_PACKAGE_TEST_VERSION:-}" ]; then - SNAPSHOT_PATTERN_ARG='-Djreleaser.project.snapshot.pattern=CAMEL_LAUNCHER_NEVER_MATCH_SNAPSHOT_PATTERN' + 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 \ + -Djreleaser.distributions=camel-cli,camel-cli-winget \ -Djreleaser.packagers="$PACKAGERS" \ - "$SNAPSHOT_PATTERN_ARG" \ + "${SNAPSHOT_PATTERN_ARGS[@]}" \ jreleaser:config jreleaser:prepare jreleaser:package \ -Djreleaser.dry.run=true 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 index 1bf232676dc6a..66c045b34c829 100644 --- 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 @@ -28,17 +28,4 @@ Install-ChocolateyZipPackage ` -ChecksumType 'sha256' ` -UnzipLocation $package -# Remove native bootstrap executables shipped in the distribution zip; Chocolatey -# uses camel.bat (shimmed via Install-BinFile below), which is architecture-neutral -# and needs no per-architecture binary. Chocolatey's packaging framework cannot yet -# declare/select per-architecture exe variants the way WinGet does: -# https://github.com/chocolatey/choco/issues/1803 -$bin = Join-Path $app_home 'bin' -foreach ($native_exe in @('camel-x64.exe', 'camel-arm64.exe')) { - $native_path = Join-Path $bin $native_exe - if (Test-Path -LiteralPath $native_path) { - Remove-Item -LiteralPath $native_path -Force -ErrorAction Stop - } -} - Install-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 index d6b3d1f58247b..c8af564a8b614 100644 --- 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 @@ -7,7 +7,6 @@ "hash": "sha256:{{distributionChecksumSha256}}", "extract_dir": "{{distributionArtifactRootEntryName}}", "bin": "bin\\{{distributionExecutableWindows}}", - "post_install": "foreach ($native_exe in @('camel-x64.exe', 'camel-arm64.exe')) { $native_path = Join-Path \"$dir\\bin\" $native_exe; if (Test-Path -LiteralPath $native_path) { Remove-Item -LiteralPath $native_path -Force -ErrorAction Stop } }", "suggest": { "JDK": [ "java/oraclejdk", 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 index d17facaf5b36a..5e909ff8e12d2 100644 --- 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 @@ -20,9 +20,10 @@ # # 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 camel-launcher distribution zip ships both camel-x64.exe and camel-arm64.exe in -# bin/ (see src/main/assembly/bin.xml), so each architecture entry selects the correct -# native exe via NestedInstallerFiles/RelativeFilePath. +# 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}} 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/main/assembly/bin.xml b/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml index 61fd0d4fab820..32432cac0ccb7 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml +++ b/dsl/camel-jbang/camel-launcher/src/main/assembly/bin.xml @@ -70,14 +70,5 @@ *.* - - ${project.build.directory} - bin - - camel-x64.exe - camel-arm64.exe - - 0755 - 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..8515d2fc85c36 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/main/assembly/winget-bin.xml @@ -0,0 +1,82 @@ + + + + winget-bin + + zip + + + + 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 + + + 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 index 65246e139a68c..57912b63adb69 100644 --- 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 @@ -119,7 +119,7 @@ void productionSupportedLtsEntryIsDocumentedBeforeExpiry() throws Exception { } @Test - void nativeExeRemovalTemplatesFailWhenFilesCannotBeRemoved() throws Exception { + void publicArchivePackagersDoNotRemoveNativeExecutables() throws Exception { String chocolatey = Files.readString(MODULE_DIR.resolve( "src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl"), StandardCharsets.UTF_8); @@ -127,10 +127,57 @@ void nativeExeRemovalTemplatesFailWhenFilesCannotBeRemoved() throws Exception { MODULE_DIR.resolve("src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl"), StandardCharsets.UTF_8); - assertFalse(chocolatey.contains("-ErrorAction SilentlyContinue"), chocolatey); - assertFalse(scoop.contains("-ErrorAction SilentlyContinue"), scoop); - assertTrue(chocolatey.contains("-ErrorAction Stop"), chocolatey); - assertTrue(scoop.contains("-ErrorAction Stop"), scoop); + assertFalse(chocolatey.contains("camel-x64.exe"), chocolatey); + assertFalse(chocolatey.contains("camel-arm64.exe"), chocolatey); + assertFalse(scoop.contains("camel-x64.exe"), scoop); + assertFalse(scoop.contains("camel-arm64.exe"), scoop); + } + + @Test + void nativeExecutablesAreConfinedToWingetArchive() throws Exception { + String publicAssembly = Files.readString(MODULE_DIR.resolve("src/main/assembly/bin.xml"), StandardCharsets.UTF_8); + String wingetAssembly + = Files.readString(MODULE_DIR.resolve("src/main/assembly/winget-bin.xml"), StandardCharsets.UTF_8); + String pom = Files.readString(MODULE_DIR.resolve("pom.xml"), StandardCharsets.UTF_8); + int nativeProfile = pom.indexOf("include-camel-exe"); + + assertFalse(publicAssembly.contains("camel-x64.exe"), + "the public ZIP/TAR assembly must not expose WinGet-only native executables"); + assertFalse(publicAssembly.contains("camel-arm64.exe"), + "the public ZIP/TAR assembly must not expose WinGet-only native executables"); + assertTrue(wingetAssembly.contains("camel-x64.exe"), wingetAssembly); + assertTrue(wingetAssembly.contains("camel-arm64.exe"), wingetAssembly); + assertTrue(wingetAssembly.contains("*.jar"), + "the WinGet archive must retain the launcher JAR beside camel.bat"); + assertTrue(wingetAssembly.contains("camel.bat"), + "the native bootstrap delegates to camel.bat beside its resolved target"); + assertTrue(wingetAssembly.contains("zip"), wingetAssembly); + assertFalse(wingetAssembly.contains("tar.gz"), wingetAssembly); + assertTrue(nativeProfile >= 0, "the native executable profile must exist"); + assertFalse(pom.substring(0, nativeProfile).contains("winget-bin.xml"), + "ordinary builds must not create an incomplete WinGet archive"); + assertTrue(pom.substring(nativeProfile).contains("winget-bin.xml"), + "the native executable profile must create the WinGet archive"); + } + + @Test + void wingetUsesDedicatedJreleaserDistribution() throws Exception { + String config = Files.readString(MODULE_DIR.resolve("jreleaser.yml"), StandardCharsets.UTF_8); + + assertTrue(config.contains("camel-cli-winget:"), config); + assertTrue(config.contains("camel-launcher-{{projectVersion}}-winget-bin.zip"), config); + assertFalse(config.substring(config.indexOf("camel-cli:"), config.indexOf("camel-cli-winget:")) + .contains("winget:"), "the public distribution must not generate the WinGet package"); + } + + @Test + void websiteWindowsInstallerUsesArchitectureNeutralBatchLauncher() throws Exception { + String installer = Files.readString(MODULE_DIR.resolve("src/install/install.ps1"), StandardCharsets.UTF_8); + + assertTrue(installer.contains("bin\\camel.bat"), installer); + assertFalse(installer.contains("camel-x64.exe"), installer); + assertFalse(installer.contains("camel-arm64.exe"), installer); + assertFalse(installer.contains("PROCESSOR_ARCHITECTURE"), installer); } @Test @@ -229,6 +276,23 @@ private Map envWithMvnStubProducingFormula(Path tmp, Path record 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)); + + return Map.of("PATH", stubDir + File.pathSeparator + System.getenv("PATH")); + } + private void addFailingCurlStub(Path tmp, Map env, Path curlRecordFile) throws IOException { Path stubDir = tmp.resolve("curl-stub-bin"); Files.createDirectories(stubDir); @@ -379,6 +443,21 @@ void stableStagesInstallersAndWritesWebsiteManifests(@TempDir Path tmp) throws E 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 ltsStagesInstallersButDoesNotWriteLatest(@TempDir Path tmp) throws Exception { writeReleaseFixture("-bin.tar.gz", "fixture-tar-lts"); 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/tooling/camel-exe/README.md b/tooling/camel-exe/README.md index 7eb3f15e55f48..5279338505c97 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,7 +38,7 @@ 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 diff --git a/tooling/camel-exe/src/main/native/camel.c b/tooling/camel-exe/src/main/native/camel.c index 8f77121d10468..e57d441df6d19 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,56 @@ 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; + } + wchar_t *buf = get_final_path(modulePath); + free(modulePath); + if (buf == NULL) { + return NULL; + } 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); From 8c1d90bbe6e3bdfc2ad3d573a189b9b0533dc690 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:56:14 -0400 Subject: [PATCH 07/23] CAMEL-23703: Document non-Maven WinGet payload design Co-authored-by: Codex --- ...6-07-19-winget-non-maven-payload-design.md | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-winget-non-maven-payload-design.md diff --git a/docs/superpowers/specs/2026-07-19-winget-non-maven-payload-design.md b/docs/superpowers/specs/2026-07-19-winget-non-maven-payload-design.md new file mode 100644 index 0000000000000..921ac5f71d693 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-winget-non-maven-payload-design.md @@ -0,0 +1,284 @@ +# CAMEL-23703: Non-Maven WinGet Payload Design + +Date: 2026-07-19 + +## Context + +The WinGet manifest needs a ZIP containing the Camel launcher distribution plus the native +`camel-x64.exe` and `camel-arm64.exe` bootstraps. The current build creates +`camel-launcher--winget-bin.zip` as an attached Maven assembly. Attaching the assembly +causes Maven's `install` and `deploy` phases to publish it as an additional `camel-launcher` +artifact with the Maven repository's `.sha1` checksum. + +The ZIP is a WinGet installer payload, not a Java dependency. It should therefore remain part of +the Apache Camel release while no longer being published to Maven Central. + +## Decision + +Generate the WinGet ZIP during the release candidate build with the existing Maven dependency and +assembly plugins, but configure the WinGet assembly with `attach=false`. Stage, vote on, sign, and +publish the generated ZIP through the Apache release distribution instead of Maven Central. + +JReleaser will continue to read the local ZIP to generate the WinGet manifest and its SHA-256 +checksum. The manifest will reference the immutable archived Apache release URL. + +## Goals + +- Keep `camel-launcher--winget-bin.zip` out of Maven Central. +- Preserve WinGet's ZIP-based portable installation and architecture-specific command selection. +- Build the ZIP from the normal launcher output and the native executables produced by + `tooling/camel-exe`. +- Produce byte-for-byte reproducible native executables and WinGet ZIPs from the same released + source, toolchain, and build instructions. +- Vote on and promote the exact bytes that WinGet later downloads. +- Fail the release when required artifacts, signatures, checksums, or reproducibility checks are + missing or inconsistent. + +## Non-goals + +- Do not create a custom Windows installer. +- Do not add download, extraction, upgrade, PATH, or uninstall behavior to `camel.exe`. +- Do not add the native executables to the standard cross-platform `-bin.zip` or `-bin.tar.gz`. +- Do not publish release payloads from snapshots. +- Do not rebuild the WinGet ZIP after the release vote. + +## Artifact Flow + +```text +released source and pinned llvm-mingw + | + v + tooling/camel-exe classified x64 and arm64 artifacts + | + | maven-dependency-plugin:copy + v + camel-launcher target staging directory + | + | maven-assembly-plugin:single, attach=false + v + camel-launcher--winget-bin.zip + | + +--> dist/dev release candidate + .asc + .sha512 + | | + | v after approval + | dist/release and Apache archive + | + +--> JReleaser checksum and WinGet manifest +``` + +## Maven Build Design + +The existing `include-camel-exe` profile remains responsible for declaring and copying the exact +native artifacts: + +- `org.apache.camel:camel-exe::exe:x64` +- `org.apache.camel:camel-exe::exe:arm64` + +The release build must resolve these artifacts from the same release reactor. It must not use +snapshot coordinates or reconstruct the ZIP from artifacts downloaded after the vote. + +The `assemble-winget-bin` execution remains bound to `package` and continues using +`src/main/assembly/winget-bin.xml`. Its execution configuration will set `attach` to `false`. +This leaves the generated ZIP in `camel-launcher/target` while excluding it from the Maven +project's attached artifacts. Consequently, Maven `install` and `deploy` will not copy it into a +local or remote Maven repository. Maven will not generate a `.sha1`, `.sha512`, or detached +signature for this non-attached ZIP. + +The assembly must be produced by Maven Assembly Plugin. Release scripts must not unpack and rezip +the distribution with platform-specific `zip`, PowerShell, or archive utilities because file order, +permissions, path separators, and timestamps may vary between implementations. + +## Reproducibility Contract + +The release build will rely on these controlled inputs: + +- The released source commit. +- The exact Maven and JDK versions required by the Camel release process. +- Maven Assembly Plugin 3.8.0 from the Camel parent plugin management. +- `project.build.outputTimestamp` from the released root POM. +- llvm-mingw version `20260616`, downloaded from the pinned filename and verified with the pinned + SHA-256 before use. +- Exact, versioned Maven coordinates for all copied artifacts. + +`project.build.outputTimestamp` supplies the timestamp for archive entries. The assembly descriptor +defines the file layout and file modes. `dependency:copy` copies the native executable bytes without +transforming them. + +A reproducibility gate will build the native executables and WinGet ZIP twice from clean output +directories using the release build instructions. It will compare SHA-256 values for: + +- `camel-x64.exe` +- `camel-arm64.exe` +- `camel-launcher--winget-bin.zip` + +Any mismatch fails the release. The first implementation must not claim that the native executable +build is reproducible until this gate passes. If it fails, the differing binaries must be inspected +before changing compiler or linker flags. + +The release process must stage the ZIP generated by the approved release candidate. Promotion must +copy that exact file from `dist/dev` to `dist/release`; it must not invoke Maven again. The detached +signature and SHA-512 file created by the Apache distribution staging step must accompany the ZIP. + +Camel's Maven release plugin already runs `deploy -Prelease` in its release checkout, and the +`release` profile enables `camel.exe.build`. After `release:perform`, the non-attached ZIP is read +from: + +```text +target/checkout/dsl/camel-jbang/camel-launcher/target/ + camel-launcher--winget-bin.zip +``` + +The release operator stages that file before starting the vote. The Nexus staging repository and +the WinGet payload candidate URL are both included in the same vote email. + +## Apache Distribution and URL Stability + +The release candidate will stage these files under the existing Camel release directory: + +- `camel-launcher--winget-bin.zip` +- `camel-launcher--winget-bin.zip.asc` +- `camel-launcher--winget-bin.zip.sha512` + +After the release vote, the files will be promoted with the other release artifacts. WinGet +publication must wait until the archived URL is available and its bytes match the promoted ZIP: + +```text +https://archive.apache.org/dist/camel/apache-camel//camel-launcher--winget-bin.zip +``` + +The WinGet manifest will use this versioned archive URL because manifests for older Camel versions +remain in `winget-pkgs` after a release leaves `downloads.apache.org`. The archive URL therefore +avoids invalidating older manifests when Apache rotates current releases. + +Before JReleaser publishes or submits the WinGet manifest, the packaging command must download or +hash the archived URL and compare it with the local ZIP. A missing URL or checksum mismatch is a +hard failure. The command may be rerun after Apache archive synchronization completes; it must not +regenerate the ZIP. + +## Release Script Integration + +A new `etc/scripts/stage-winget-distro.sh` command will prepare the WinGet release candidate. It +accepts the release version, candidate number, and path to the ZIP produced by `release:perform`. +It validates the filename and release version, creates the detached signature and SHA-512 file, +prepares an SVN working copy under: + +```text +https://dist.apache.org/repos/dist/dev/camel/apache-camel/-rc/ +``` + +Like the existing `release-distro.sh`, it stops before the remote commit and prints the exact SVN +status and commit commands for the release operator to review. + +The staging script, not Maven, creates +`camel-launcher--winget-bin.zip.asc` and +`camel-launcher--winget-bin.zip.sha512`. This matches the existing Apache distribution +flow in `release-distro.sh`, which removes Maven repository `.sha1` files and creates `.sha512` +files before preparing the `dist/release` working copy. + +After a successful vote, `etc/scripts/release-distro.sh` will accept an optional WinGet candidate +number. When supplied, it exports the approved ZIP, signature, and checksum from `dist/dev`, verifies +them, and adds those exact files to the local `dist/release` working copy alongside the artifacts it +already retrieves from the released Maven repository. The operator reviews and commits that combined +working copy using the existing release process, then removes the promoted candidate directory from +`dist/dev`. + +The release guide will place WinGet candidate staging after `release:perform` and before the vote. It +will place candidate promotion in the existing `release-distro.sh` step after the vote. + +## JReleaser Design + +The dedicated `camel-cli-winget` distribution remains because it describes a payload whose contents +differ from the public `camel-cli` ZIP. Its artifact path continues to reference the local +`target/camel-launcher--winget-bin.zip`. + +The WinGet `downloadUrl` changes from the Maven Central coordinate to the versioned Apache archive +URL. The existing custom installer template continues to emit two installer entries that share the +same ZIP and checksum while selecting different nested executables: + +- x64 selects `bin/camel-x64.exe`. +- arm64 selects `bin/camel-arm64.exe`. + +JReleaser must calculate `distributionChecksumSha256` from the same local ZIP whose SHA-512 and +signature were staged for the release. The packaging tests will assert the final URL and compare the +manifest SHA-256 with the local and remotely published bytes. + +## Failure Handling + +The release or package preparation fails when any of the following occurs: + +- Either native executable is missing. +- The WinGet ZIP is missing either native executable or required launcher content. +- A clean rebuild produces a different executable or ZIP checksum. +- Maven installs or deploys the WinGet ZIP as an attached artifact. +- A Maven-generated `.sha1` for the WinGet ZIP appears in the release staging input, indicating that + the ZIP was still attached and deployed. +- The detached signature or SHA-512 file is missing from the release candidate. +- The archived Apache URL is unavailable. +- The archived ZIP differs from the approved local ZIP. +- The generated WinGet manifest checksum differs from the approved ZIP. +- A snapshot version reaches release packaging. + +No failure may be converted into a skip. The operator may retry only availability checks after +Apache distribution synchronization; the approved artifact itself remains unchanged. + +## Verification + +The implementation will add or update focused verification for these behaviors: + +1. The native executable integration test verifies staging and both entries in the WinGet ZIP. +2. The standard ZIP and TAR tests verify that native executables remain excluded. +3. A Maven integration check deploys into a temporary file repository and verifies that no + `camel-launcher--winget-bin.zip` or corresponding `.sha1` is deployed. +4. A two-build reproducibility check compares the native executable and WinGet ZIP hashes. +5. Package-plan tests verify the Apache archive download URL and reject Maven Central as the WinGet + payload host. +6. Manifest tests verify both architectures, nested executable paths, and the exact ZIP checksum. +7. Release staging verification checks the ZIP, `.asc`, and `.sha512` files before promotion. +8. Remote verification compares the archived ZIP with the approved local ZIP before WinGet + publication. + +## Expected Code and Documentation Scope + +Implementation is expected to update only the files required by this release flow: + +- `dsl/camel-jbang/camel-launcher/pom.xml` +- `dsl/camel-jbang/camel-launcher/jreleaser.yml` +- `dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh` +- Focused launcher packaging and manifest tests +- Launcher and native executable packaging documentation +- `.github/workflows/camel-launcher-native-exe.yml` +- `etc/scripts/stage-winget-distro.sh` +- `etc/scripts/release-distro.sh` +- `docs/user-manual/modules/ROOT/pages/release-guide.adoc` + +No unrelated release or launcher refactoring is included. + +## Alternatives Rejected + +### Include native executables in the standard launcher ZIP + +This would remove the dedicated payload but would add two Windows-only binaries to every launcher +ZIP consumer. The approved design keeps the standard distribution architecture-neutral. + +### Custom installer executable + +A custom installer would still require a published installer artifact and would add network access, +checksum verification, extraction, silent installation, upgrade, rollback, PATH, and uninstall +behavior. WinGet's portable ZIP support already handles installation lifecycle without that added +security and maintenance surface. + +### Generate the ZIP after release publication + +Rebuilding or first generating the ZIP after the release vote would mean WinGet downloads bytes that +were not part of the approved release candidate. It would also make reproducibility and provenance +harder to verify. The approved ZIP is therefore created and staged before the vote. + +## Success Criteria + +- Maven Central contains no `camel-launcher--winget-bin.zip` artifact. +- The approved Apache release contains the ZIP, detached signature, and SHA-512 checksum. +- Two clean release builds produce identical x64 and arm64 executables and an identical WinGet ZIP. +- The promoted ZIP is byte-for-byte identical to the approved release candidate. +- The WinGet manifest references the immutable Apache archive URL and contains the correct SHA-256. +- WinGet selects `camel-x64.exe` on x64 and `camel-arm64.exe` on arm64 while retaining the complete + launcher distribution beside the selected executable. From 643f7896de237afad0d1cd9e39ed6442738cbf90 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:19:37 -0400 Subject: [PATCH 08/23] CAMEL-23703: Plan non-Maven WinGet payload implementation Co-authored-by: Codex --- .../2026-07-19-winget-non-maven-payload.md | 964 ++++++++++++++++++ 1 file changed, 964 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-winget-non-maven-payload.md diff --git a/docs/superpowers/plans/2026-07-19-winget-non-maven-payload.md b/docs/superpowers/plans/2026-07-19-winget-non-maven-payload.md new file mode 100644 index 0000000000000..5f5b54c3d3025 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-winget-non-maven-payload.md @@ -0,0 +1,964 @@ +# WinGet Non-Maven Payload Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the WinGet launcher ZIP reproducibly from `tooling/camel-exe` without attaching or publishing that ZIP as a Maven artifact, then vote on and publish the exact ZIP through the Apache distribution archive. + +**Architecture:** Keep the current `maven-dependency-plugin:copy` and Maven Assembly Plugin flow, but set the WinGet assembly execution to `attach=false`. Add CI gates for byte reproducibility and Maven repository exclusion, stage the local ZIP with its Apache signature and SHA-512 checksum under `dist/dev`, promote the approved files unchanged to `dist/release`, and make JReleaser verify the archived bytes before generating WinGet packaging output. + +**Tech Stack:** Maven 3.9.12+, Maven Assembly Plugin 3.8.0, Maven Dependency Plugin, JReleaser 1.25.0, Bash, JUnit 5, llvm-mingw 20260616, GitHub Actions, Apache Subversion distribution repositories. + +## Global Constraints + +- Do not create a custom Windows installer. +- Do not add dependencies. +- Keep `camel-x64.exe` and `camel-arm64.exe` out of the standard `-bin.zip` and `-bin.tar.gz` archives. +- Build both native executables from `tooling/camel-exe` with llvm-mingw version `20260616` and its pinned SHA-256. +- Build `camel-launcher--winget-bin.zip` only with Maven Assembly Plugin 3.8.0 and `project.build.outputTimestamp`. +- Do not attach, install, or deploy the WinGet ZIP through Maven. Maven must not create a WinGet ZIP `.sha1` sidecar. +- The Apache distribution staging step, not Maven, creates the WinGet ZIP `.asc` and `.sha512` files. +- Promote the approved `dist/dev` ZIP, signature, and checksum without rebuilding or repacking them. +- Point WinGet manifests at `https://archive.apache.org/dist/camel/apache-camel//camel-launcher--winget-bin.zip`. +- Treat missing artifacts, checksum mismatches, signature failures, unavailable archive URLs, and reproducibility differences as hard failures. +- Keep Scoop's existing Maven Central `.sha1` behavior unchanged because it applies to the standard launcher archive. +- Use American English and do not introduce em dashes in code comments or documentation. +- Before every `git add`, inspect the exact diff and scan it for secret-shaped content. +- Every implementation commit uses a `CAMEL-23703:` subject and the trailer `Co-authored-by: Codex `. + +--- + +## File Map + +- `dsl/camel-jbang/camel-launcher/pom.xml`: builds the local, non-attached WinGet ZIP. +- `dsl/camel-jbang/camel-launcher/src/main/assembly/winget-bin.xml`: remains the single definition of WinGet ZIP contents. +- `dsl/camel-jbang/camel-launcher/jreleaser.yml`: points only the WinGet distribution at the Apache archive. +- `dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh`: verifies that the local WinGet ZIP exactly matches the archived ZIP before JReleaser runs. +- `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java`: verifies the Maven, workflow, JReleaser, and package-script contracts. +- `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java`: exercises candidate staging outputs and verifies promotion-script requirements. +- `.github/workflows/camel-launcher-native-exe.yml`: builds twice, compares hashes, deploys to a temporary Maven repository, and rejects a deployed WinGet artifact. +- `etc/scripts/stage-winget-distro.sh`: signs, checksums, and prepares the local `dist/dev` candidate working copy without committing it. +- `etc/scripts/release-distro.sh`: imports the approved candidate files into the existing `dist/release` working copy and verifies them first. +- `dsl/camel-jbang/camel-launcher/README.md`: documents the local-only Maven artifact and Apache archive URL. +- `tooling/camel-exe/README.md`: documents the reproducibility gate. +- `docs/user-manual/modules/ROOT/pages/release-guide.adoc`: documents pre-vote staging, vote evidence, post-vote promotion, and archive synchronization. + +### Task 1: Make the Maven Assembly Local-Only + +**Files:** +- Modify: `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java:137-161` +- Modify: `dsl/camel-jbang/camel-launcher/pom.xml:413-424` +- Modify: `dsl/camel-jbang/camel-launcher/README.md:11-30` + +**Interfaces:** +- Consumes: the existing `include-camel-exe` profile, `camel-exe` artifacts with type `exe` and classifiers `x64` and `arm64`, and `src/main/assembly/winget-bin.xml`. +- Produces: `target/camel-launcher-${project.version}-winget-bin.zip` during `package`, while leaving `project.getAttachedArtifacts()` unchanged. + +- [ ] **Step 1: Extend the existing packaging contract test** + +Replace the final two assertions in `nativeExecutablesAreConfinedToWingetArchive()` with an execution-scoped check so another assembly execution cannot satisfy the test accidentally: + +```java + String nativeProfilePom = pom.substring(nativeProfile); + int wingetExecutionStart = nativeProfilePom.indexOf("assemble-winget-bin"); + int wingetExecutionEnd = nativeProfilePom.indexOf("", wingetExecutionStart); + String wingetExecution = nativeProfilePom.substring(wingetExecutionStart, wingetExecutionEnd); + + assertTrue(wingetExecutionStart >= 0, "the native executable profile must create the WinGet archive"); + assertTrue(wingetExecution.contains("src/main/assembly/winget-bin.xml"), + wingetExecution); + assertTrue(wingetExecution.contains("false"), + "the WinGet payload must remain a local release file, not an attached Maven artifact"); +``` + +- [ ] **Step 2: Run the focused test and confirm the intended failure** + +Run: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn -Dtest=PackagePlanTest#nativeExecutablesAreConfinedToWingetArchive test +``` + +Expected: FAIL because the `assemble-winget-bin` execution does not contain `false`. + +- [ ] **Step 3: Disable attachment only for the WinGet assembly** + +Add `attach` beside the existing descriptor configuration in `pom.xml`: + +```xml + + false + + src/main/assembly/winget-bin.xml + + +``` + +Do not change the standard launcher assemblies or the attached `camel-exe` classifier artifacts. The launcher still consumes those classifier artifacts through `maven-dependency-plugin:copy`. + +- [ ] **Step 4: Document the Maven lifecycle result** + +Replace the WinGet build paragraph in `dsl/camel-jbang/camel-launcher/README.md` with: + +```markdown +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, absent from the public archives, and present in the WinGet ZIP: +``` + +- [ ] **Step 5: Run the focused test and package check** + +Run: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn -Dtest=PackagePlanTest#nativeExecutablesAreConfinedToWingetArchive test +``` + +Expected: PASS with one test run and no failures. + +The native build itself is covered in Task 2 because it requires the pinned llvm-mingw toolchain. + +- [ ] **Step 6: Review, scan, and commit** + +Run: + +```bash +git diff -- dsl/camel-jbang/camel-launcher/pom.xml dsl/camel-jbang/camel-launcher/README.md dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java +git diff --check +git diff | rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,})' +``` + +Expected: the first command shows only this task's changes, `git diff --check` is silent, and the secret scan is silent. Then run: + +```bash +git add dsl/camel-jbang/camel-launcher/pom.xml \ + dsl/camel-jbang/camel-launcher/README.md \ + dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java +git commit -m "CAMEL-23703: Keep WinGet payload out of Maven repositories" \ + -m "Co-authored-by: Codex " +``` + +### Task 2: Prove Reproducibility and Maven Repository Exclusion + +**Files:** +- Modify: `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java:163-172` +- Modify: `.github/workflows/camel-launcher-native-exe.yml:188-202` +- Modify: `tooling/camel-exe/README.md:37-48` + +**Interfaces:** +- Consumes: the local-only ZIP from Task 1, the pinned llvm-mingw setup action, and the existing three-module native launcher build. +- Produces: a CI gate that compares SHA-256 values across two clean builds and rejects any WinGet ZIP or WinGet ZIP `.sha1` in a temporary Maven deployment repository. + +- [ ] **Step 1: Add a workflow contract test** + +Add this method to `PackagePlanTest`: + +```java + @Test + void nativeWorkflowChecksReproducibilityAndMavenExclusion() throws Exception { + String workflow = Files.readString( + MODULE_DIR.resolve("../../../.github/workflows/camel-launcher-native-exe.yml").normalize(), + StandardCharsets.UTF_8); + + assertTrue(workflow.contains("-name 'camel-launcher-*-winget-bin.zip'"), workflow); + assertTrue(workflow.contains("sha256sum --check \"$HASH_FILE\""), workflow); + assertTrue(workflow.contains("-DaltSnapshotDeploymentRepository=winget-check::file://$MAVEN_REPO"), workflow); + assertTrue(workflow.contains("-name '*-winget-bin.zip'"), workflow); + assertTrue(workflow.contains("-name '*-winget-bin.zip.sha1'"), workflow); + } +``` + +- [ ] **Step 2: Run the new test and confirm it fails** + +Run: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn -Dtest=PackagePlanTest#nativeWorkflowChecksReproducibilityAndMavenExclusion test +``` + +Expected: FAIL because the workflow has no second-build hash comparison or temporary deployment check, and its current ZIP glob is not WinGet-specific. + +- [ ] **Step 3: Make the archive assertion unambiguous** + +Change the ZIP lookup in `.github/workflows/camel-launcher-native-exe.yml` to: + +```yaml + ZIP=$(find dsl/camel-jbang/camel-launcher/target -maxdepth 1 -name 'camel-launcher-*-winget-bin.zip' -print -quit) +``` + +- [ ] **Step 4: Add the clean rebuild checksum gate** + +Insert this step after the archive-content assertion: + +```yaml + - 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" + + mvn -P apache-snapshots \ + -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ + -Dcamel.exe.build=true -DskipTests + + sha256sum --check "$HASH_FILE" +``` + +The stored paths are repository-relative, so the second clean build recreates files at the exact paths recorded by `sha256sum`. + +- [ ] **Step 5: Add the temporary Maven deployment gate** + +Insert this step after the reproducibility step: + +```yaml + - name: Verify WinGet archive is not deployed by Maven + run: | + MAVEN_REPO="$RUNNER_TEMP/winget-maven-repo" + mvn -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 + + DEPLOYED_WINGET_FILES=$(find "$MAVEN_REPO" -type f \ + \( -name '*-winget-bin.zip' -o -name '*-winget-bin.zip.sha1' \) -print) + if [ -n "$DEPLOYED_WINGET_FILES" ]; then + echo "ERROR: Maven deployed the local-only WinGet payload:" + echo "$DEPLOYED_WINGET_FILES" + exit 1 + fi +``` + +Do not accept the absence of only `.sha512` as proof. This gate must specifically reject the ZIP and the Maven repository's `.sha1` sidecar. + +- [ ] **Step 6: Document the reproducibility gate** + +Append this paragraph after the release-build command in `tooling/camel-exe/README.md`: + +```markdown +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. +``` + +- [ ] **Step 7: Run the focused contract test and local syntax checks** + +Run: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn -Dtest=PackagePlanTest#nativeWorkflowChecksReproducibilityAndMavenExclusion test +cd ../../.. +git diff --check +``` + +Expected: the JUnit test passes and `git diff --check` is silent. Do not report the native binaries as reproducible until the GitHub Actions job, or the equivalent pinned-toolchain command, completes both builds successfully. + +- [ ] **Step 8: Review, scan, and commit** + +Run: + +```bash +git diff -- .github/workflows/camel-launcher-native-exe.yml tooling/camel-exe/README.md dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java +git diff | rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,})' +``` + +Expected: only this task's changes appear and the secret scan is silent. Then run: + +```bash +git add .github/workflows/camel-launcher-native-exe.yml \ + tooling/camel-exe/README.md \ + dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java +git commit -m "CAMEL-23703: Verify reproducible WinGet packaging" \ + -m "Co-authored-by: Codex " +``` + +### Task 3: Use and Verify the Archived Apache Payload + +**Files:** +- Modify: `dsl/camel-jbang/camel-launcher/jreleaser.yml:139-155` +- Modify: `dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh:147-214` +- Modify: `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java:41-72,163-171,236-305,412-555` +- Modify: `dsl/camel-jbang/camel-launcher/README.md:107-141` + +**Interfaces:** +- Consumes: local `target/camel-launcher--winget-bin.zip` and the immutable Apache archive URL for the same version and filename. +- Produces: JReleaser WinGet output only after `cmp` proves that the local ZIP and archived ZIP are byte-identical. + +- [ ] **Step 1: Add the archive URL assertions** + +Replace `wingetUsesDedicatedJreleaserDistribution()` with: + +```java + @Test + void wingetUsesDedicatedJreleaserDistribution() throws Exception { + String config = Files.readString(MODULE_DIR.resolve("jreleaser.yml"), StandardCharsets.UTF_8); + int wingetStart = config.indexOf(" camel-cli-winget:"); + String wingetDistribution = config.substring(wingetStart); + + assertTrue(wingetStart >= 0, config); + assertTrue(wingetDistribution.contains("camel-launcher-{{projectVersion}}-winget-bin.zip"), config); + assertTrue(wingetDistribution.contains( + "https://archive.apache.org/dist/camel/apache-camel/{{projectVersion}}/{{artifactFile}}"), config); + assertFalse(wingetDistribution.contains("repo1.maven.org"), wingetDistribution); + assertFalse(config.substring(config.indexOf("camel-cli:"), wingetStart).contains("winget:"), + "the public distribution must not generate the WinGet package"); + } +``` + +- [ ] **Step 2: Add deterministic local and remote ZIP fixtures** + +Extend `cleanupFixtures()` with: + +```java + Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-winget-bin.zip")); +``` + +Add this helper below `writeReleaseFixture`: + +```java + Path writeWingetFixture(String content) throws IOException { + return writeReleaseFixture("-winget-bin.zip", content); + } +``` + +In both `testModeEnvWithMvnStub` and `envWithMvnStubProducingFormula`, create an identical remote source and add the guarded test-only variable: + +```java + Path winget = writeWingetFixture("fixture-winget"); + env.put("CAMEL_PACKAGE_TEST_WINGET_REMOTE", winget.toString()); +``` + +Change `productionStyleEnvWithMvnStub` to return a mutable `LinkedHashMap`, create the local fixture, and install a successful `curl` stub: + +```java + 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; +``` + +Keep the existing `mvn` stub in that method unchanged. Do not use a test variable in this production-style helper, because this path must exercise the real `curl` branch. + +- [ ] **Step 3: Add byte-match, mismatch, and guard tests** + +Add these methods to `PackagePlanTest`: + +```java + @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)); + } +``` + +Also add `writeWingetFixture("fixture-winget")` before invoking the script in any happy-path test whose environment helper does not create it. Preserve `scoopAutoupdateUsesPublishedMavenCentralSha1Sidecar()` unchanged. + +Extend `wingetInstallerManifestUsesLatestAcceptedSchema()` so the template continues to bind both architectures and both installer checksums to this one distribution ZIP: + +```java + 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, winget.split("InstallerSha256: \\{\\{distributionChecksumSha256\\}\\}", -1).length - 1, + "both architecture entries must use the checksum of the same approved ZIP"); +``` + +- [ ] **Step 4: Run the new tests and confirm they fail for the intended reasons** + +Run: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn -Dtest=PackagePlanTest#wingetUsesDedicatedJreleaserDistribution+productionPrepareVerifiesTheArchivedWingetPayload+prepareRejectsAnArchivedWingetPayloadWithDifferentBytes+wingetRemoteOverrideRequiresExplicitTestMode+missingWingetPayloadFailsBeforeJReleaser+wingetInstallerManifestUsesLatestAcceptedSchema test +``` + +Expected: FAIL because JReleaser still uses Maven Central and `camel-package.sh` neither locates nor verifies the WinGet ZIP. + +- [ ] **Step 5: Point only WinGet at the Apache archive** + +Change the `camel-cli-winget` block in `jreleaser.yml` to: + +```yaml + winget: + active: RELEASE + downloadUrl: "https://archive.apache.org/dist/camel/apache-camel/{{projectVersion}}/{{artifactFile}}" +``` + +Do not change the Maven Central URLs for Homebrew, SDKMAN, Scoop, or Chocolatey. + +- [ ] **Step 6: Verify the archived bytes before any staging or JReleaser work** + +Immediately after the snapshot guard in `camel-package.sh`, reject an unguarded test override: + +```bash +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 +``` + +Add the WinGet paths beside `TAR` and `ZIP`: + +```bash +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")" +``` + +Add this check after the standard ZIP check and before website staging: + +```bash +if [ ! -f "$WINGET_ZIP" ]; then + echo "Error: WinGet release ZIP not found: $WINGET_ZIP" 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 +``` + +This comparison must happen before creating the website staging directory and before invoking Maven/JReleaser. Do not regenerate the ZIP after a mismatch. + +- [ ] **Step 7: Document the distinct URL rules** + +Replace the sentence that says all other packagers use Maven Central in `dsl/camel-jbang/camel-launcher/README.md` with: + +```markdown +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. +``` + +Append this sentence to the native bootstrap subsection: + +```markdown +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. +``` + +- [ ] **Step 8: Run all package-plan tests** + +Run: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn -Dtest=PackagePlanTest test +``` + +Expected: all `PackagePlanTest` tests pass. A test that deliberately supplies different local and remote bytes must fail inside the script and pass at the JUnit assertion level. + +- [ ] **Step 9: Review, scan, and commit** + +Run: + +```bash +git diff -- dsl/camel-jbang/camel-launcher/jreleaser.yml \ + dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh \ + dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java \ + dsl/camel-jbang/camel-launcher/README.md +git diff --check +git diff | rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,})' +``` + +Expected: only archive-verification changes appear, whitespace checks are silent, and the secret scan is silent. Then run: + +```bash +git add dsl/camel-jbang/camel-launcher/jreleaser.yml \ + dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh \ + dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java \ + dsl/camel-jbang/camel-launcher/README.md +git commit -m "CAMEL-23703: Verify archived WinGet payload bytes" \ + -m "Co-authored-by: Codex " +``` + +### Task 4: Stage and Promote the Approved Apache Distribution Files + +**Files:** +- Create: `etc/scripts/stage-winget-distro.sh` +- Modify: `etc/scripts/release-distro.sh:18-75` +- Create: `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java` +- Modify: `docs/user-manual/modules/ROOT/pages/release-guide.adoc:254-278,370-398` + +**Interfaces:** +- Consumes: `stage-winget-distro.sh [work-directory]` and `release-distro.sh [temp-directory] [winget-candidate]`. +- Produces: an uncommitted `dist/dev` working copy containing the ZIP, `.asc`, and `.sha512`, followed after the vote by an uncommitted `dist/release` working copy containing the same verified bytes. + +- [ ] **Step 1: Add a real staging-output test** + +Create `WingetDistroScriptsTest.java` with the ASF license header and this class body: + +```java +package org.apache.camel.dsl.jbang.launcher; + +import java.io.File; +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.HexFormat; +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.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"; + + @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 releaseScriptExportsAndVerifiesTheApprovedCandidate() throws Exception { + String script = Files.readString(RELEASE_SCRIPT, StandardCharsets.UTF_8); + + assertTrue(script.contains("WINGET_CANDIDATE=${3:-}"), script); + assertTrue(script.contains("https://dist.apache.org/repos/dist/dev/camel/apache-camel/"), script); + assertTrue(script.contains("svn export"), script); + assertTrue(script.contains("WINGET_NAME=\"camel-launcher-${VERSION}-winget-bin.zip\""), script); + assertTrue(script.contains("for suffix in \"\" \".asc\" \".sha512\""), script); + assertTrue(script.contains("sha512sum -c"), script); + assertTrue(script.contains("gpg --verify"), script); + assertFalse(script.contains("mvn "), "promotion must not rebuild the approved WinGet payload"); + } + + private static void writeExecutable(Path path, String content) throws Exception { + Files.writeString(path, content, StandardCharsets.UTF_8); + assertTrue(path.toFile().setExecutable(true)); + } +} +``` + +- [ ] **Step 2: Run the tests and confirm both implementation-dependent tests fail** + +Run: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn -Dtest=WingetDistroScriptsTest test +``` + +Expected: FAIL because `stage-winget-distro.sh` does not exist and `release-distro.sh` has no candidate export or verification logic. + +- [ ] **Step 3: Add the focused candidate staging script** + +Create `etc/scripts/stage-winget-distro.sh` with the ASF license header followed by: + +```bash +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" +gpg --batch --verbose --armor --detach-sign \ + --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/" +``` + +Make the script executable with `chmod +x etc/scripts/stage-winget-distro.sh`. This is a file-mode change, not a content rewrite. + +- [ ] **Step 4: Import and verify the voted candidate during release promotion** + +Add these variables near the top of `etc/scripts/release-distro.sh`: + +```bash +WINGET_CANDIDATE=${3:-} +DIST_DEV_REPO="https://dist.apache.org/repos/dist/dev/camel/apache-camel" +``` + +Change the usage validation to accept and validate the optional positive candidate number: + +```bash +if [ -z "${VERSION}" -o ! -d "${DOWNLOAD}" ]; then + echo "Usage: release-distro.sh [temp-directory] [winget-candidate]" + 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 +``` + +Immediately after the existing loop that creates Maven distribution `.sha512` files, add: + +```bash +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 + 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 +``` + +Do not invoke Maven, Assembly Plugin, `zip`, or any repacking command in this promotion path. The existing wildcard copy then places all three exported files into the local `dist/release` working copy. + +- [ ] **Step 5: Document the pre-vote candidate flow** + +After the main Camel `release:perform` command in `release-guide.adoc`, add: + +```adoc +. 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. +``` + +- [ ] **Step 6: Document post-vote promotion and archive availability** + +Change the existing release distribution command to: + +```adoc + ./release-distro.sh +``` + +Add below it: + +```adoc +* 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. + +* 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" +``` + +- [ ] **Step 7: Run script behavior, syntax, and package tests** + +Run: + +```bash +bash -n etc/scripts/stage-winget-distro.sh +bash -n etc/scripts/release-distro.sh +cd dsl/camel-jbang/camel-launcher +mvn -Dtest=WingetDistroScriptsTest,PackagePlanTest test +``` + +Expected: both Bash syntax checks return zero. The staging test verifies the exact ZIP bytes, detached signature file, SHA-512 content, local SVN add, and absence of a commit. All package-plan tests pass. + +- [ ] **Step 8: Review, scan, and commit** + +Run: + +```bash +git diff -- etc/scripts/stage-winget-distro.sh etc/scripts/release-distro.sh \ + dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java \ + docs/user-manual/modules/ROOT/pages/release-guide.adoc +git diff --check +git diff | rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,})' +``` + +Expected: only candidate staging, promotion, tests, and release documentation appear. Whitespace and secret scans are silent. Then run: + +```bash +git add etc/scripts/stage-winget-distro.sh etc/scripts/release-distro.sh \ + dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java \ + docs/user-manual/modules/ROOT/pages/release-guide.adoc +git commit -m "CAMEL-23703: Stage WinGet payload with Apache distributions" \ + -m "Co-authored-by: Codex " +``` + +## Final Verification Gate + +- [ ] Run the launcher module's complete test suite from its module directory: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn verify +``` + +Expected: BUILD SUCCESS with no skipped or excluded test failures. + +- [ ] Apply the module's normal formatting and generated-file check: + +```bash +cd dsl/camel-jbang/camel-launcher +mvn -DskipTests install +``` + +Expected: BUILD SUCCESS and `git status --short` shows no unexpected generated changes. + +- [ ] Run the native release gate with the pinned llvm-mingw toolchain available: + +```bash +cd ../../.. +mvn -P apache-snapshots \ + -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ + -Dcamel.exe.build=true -DskipTests +``` + +Expected: BUILD SUCCESS, both executables are present only in the WinGet ZIP, and the standard ZIP and TAR remain free of native executables. If the pinned compiler is unavailable on the current machine, report this command as unverified and require the GitHub Actions `camel-launcher-native` job to supply the evidence. + +- [ ] Verify the final history and worktree: + +```bash +git diff --check +git status --short --branch +git log --oneline -6 +``` + +Expected: no whitespace errors, no unintended uncommitted files, and four focused implementation commits after the approved design and plan commits. From e2f01cf5cf24b0cd39b26287f24ea394b36a93c0 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:23:50 -0400 Subject: [PATCH 09/23] CAMEL-23703: Keep WinGet payload out of Maven repositories Co-authored-by: Codex --- dsl/camel-jbang/camel-launcher/README.md | 2 ++ dsl/camel-jbang/camel-launcher/pom.xml | 1 + .../camel/dsl/jbang/launcher/PackagePlanTest.java | 13 +++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/dsl/camel-jbang/camel-launcher/README.md b/dsl/camel-jbang/camel-launcher/README.md index 206cff782b487..f0ee40d2c5cf2 100644 --- a/dsl/camel-jbang/camel-launcher/README.md +++ b/dsl/camel-jbang/camel-launcher/README.md @@ -19,6 +19,8 @@ This will create: 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, absent from the public archives, and present in the WinGet ZIP: diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index ae693efd0ce16..94ac81f374448 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -417,6 +417,7 @@ single + false src/main/assembly/winget-bin.xml 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 index 57912b63adb69..e7ced555218ee 100644 --- 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 @@ -156,8 +156,17 @@ void nativeExecutablesAreConfinedToWingetArchive() throws Exception { assertTrue(nativeProfile >= 0, "the native executable profile must exist"); assertFalse(pom.substring(0, nativeProfile).contains("winget-bin.xml"), "ordinary builds must not create an incomplete WinGet archive"); - assertTrue(pom.substring(nativeProfile).contains("winget-bin.xml"), - "the native executable profile must create the WinGet archive"); + String nativeProfilePom = pom.substring(nativeProfile); + int wingetExecutionStart = nativeProfilePom.indexOf("assemble-winget-bin"); + assertTrue(wingetExecutionStart >= 0, "the native executable profile must create the WinGet archive"); + int wingetExecutionEnd = nativeProfilePom.indexOf("", wingetExecutionStart); + assertTrue(wingetExecutionEnd >= 0, "the WinGet assembly execution must be complete"); + String wingetExecution = nativeProfilePom.substring(wingetExecutionStart, wingetExecutionEnd); + + assertTrue(wingetExecution.contains("src/main/assembly/winget-bin.xml"), + wingetExecution); + assertTrue(wingetExecution.contains("false"), + "the WinGet payload must remain a local release file, not an attached Maven artifact"); } @Test From 0206b352713c47c71b074c962349f0d9521c4d0e Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:28:39 -0400 Subject: [PATCH 10/23] CAMEL-23703: Verify reproducible WinGet packaging Co-authored-by: Codex --- .../workflows/camel-launcher-native-exe.yml | 31 ++++++++++++++++++- .../dsl/jbang/launcher/PackagePlanTest.java | 14 +++++++++ tooling/camel-exe/README.md | 6 ++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/camel-launcher-native-exe.yml b/.github/workflows/camel-launcher-native-exe.yml index 952aadf8c7009..4cc50b8ee213f 100644 --- a/.github/workflows/camel-launcher-native-exe.yml +++ b/.github/workflows/camel-launcher-native-exe.yml @@ -191,7 +191,7 @@ jobs: -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 +200,32 @@ 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" + + mvn -P apache-snapshots \ + -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ + -Dcamel.exe.build=true -DskipTests + + sha256sum --check "$HASH_FILE" + - name: Verify WinGet archive is not deployed by Maven + run: | + MAVEN_REPO="$RUNNER_TEMP/winget-maven-repo" + mvn -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" + + DEPLOYED_WINGET_FILES=$(find "$MAVEN_REPO" -type f \ + \( -name '*-winget-bin.zip' -o -name '*-winget-bin.zip.sha1' \) -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/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 index e7ced555218ee..f42c976ac97fd 100644 --- 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 @@ -179,6 +179,20 @@ void wingetUsesDedicatedJreleaserDistribution() throws Exception { .contains("winget:"), "the public distribution must not generate the WinGet package"); } + @Test + void nativeWorkflowChecksReproducibilityAndMavenExclusion() throws Exception { + String workflow = Files.readString( + MODULE_DIR.resolve("../../../.github/workflows/camel-launcher-native-exe.yml").normalize(), + StandardCharsets.UTF_8); + + assertTrue(workflow.contains("-name 'camel-launcher-*-winget-bin.zip'"), workflow); + assertTrue(workflow.contains("sha256sum --check \"$HASH_FILE\""), workflow); + assertTrue(workflow.contains("-DaltSnapshotDeploymentRepository=\"winget-check::file://$MAVEN_REPO\""), + workflow); + assertTrue(workflow.contains("-name '*-winget-bin.zip'"), workflow); + assertTrue(workflow.contains("-name '*-winget-bin.zip.sha1'"), workflow); + } + @Test void websiteWindowsInstallerUsesArchitectureNeutralBatchLauncher() throws Exception { String installer = Files.readString(MODULE_DIR.resolve("src/install/install.ps1"), StandardCharsets.UTF_8); diff --git a/tooling/camel-exe/README.md b/tooling/camel-exe/README.md index 5279338505c97..f1a2cdbb7e685 100644 --- a/tooling/camel-exe/README.md +++ b/tooling/camel-exe/README.md @@ -44,5 +44,11 @@ Release and integration builds that produce the WinGet launcher ZIP also build t 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. From cefb1a5fbe8979abbef20ba154690aebf71f07bd Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:33:15 -0400 Subject: [PATCH 11/23] CAMEL-23703: Verify archived WinGet payload bytes Co-authored-by: Codex --- dsl/camel-jbang/camel-launcher/README.md | 8 +- dsl/camel-jbang/camel-launcher/jreleaser.yml | 10 +- .../src/jreleaser/bin/camel-package.sh | 32 +++++ .../dsl/jbang/launcher/PackagePlanTest.java | 114 +++++++++++++++++- 4 files changed, 152 insertions(+), 12 deletions(-) diff --git a/dsl/camel-jbang/camel-launcher/README.md b/dsl/camel-jbang/camel-launcher/README.md index f0ee40d2c5cf2..64432e8ccd4d6 100644 --- a/dsl/camel-jbang/camel-launcher/README.md +++ b/dsl/camel-jbang/camel-launcher/README.md @@ -130,8 +130,9 @@ 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**; the other packagers -(SDKMAN, WinGet, Scoop, Chocolatey) use `repo1.maven.org` directly. +`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. ### Native bootstrap executables @@ -140,5 +141,8 @@ The dedicated `camel-launcher--winget-bin.zip` ships `camel-x64.exe` an (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 index ab1dfc19d2f70..8c3abf77adc91 100644 --- a/dsl/camel-jbang/camel-launcher/jreleaser.yml +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -39,10 +39,10 @@ # - 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. -# - Every packager overrides `downloadUrl` to the Maven Central coordinates -# (matching Phase 3A's installers and docs/getting-started.adoc); JReleaser's -# default download URL points at a GitHub release asset, which this project -# does not publish artifacts to. +# - 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 @@ -147,7 +147,7 @@ distributions: - path: "dsl/camel-jbang/camel-launcher/target/camel-launcher-{{projectVersion}}-winget-bin.zip" winget: active: RELEASE - downloadUrl: "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/{{projectVersion}}/{{artifactFile}}" + 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: diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh index d7ac095349b3e..34d32b51cb41e 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -165,9 +165,16 @@ case "$PROJECT_VERSION" in ;; 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" @@ -179,6 +186,10 @@ 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 @@ -188,6 +199,27 @@ if [ ! -f "$INSTALL_PS1_SRC" ]; then 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" 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 index f42c976ac97fd..61c69c812b9c5 100644 --- 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 @@ -67,6 +67,10 @@ Path writeReleaseFixture(String suffix, String content) throws IOException { return file; } + Path writeWingetFixture(String content) throws IOException { + return writeReleaseFixture("-winget-bin.zip", content); + } + Path websiteDir() { return MODULE_DIR.resolve("target/jreleaser/website"); } @@ -82,6 +86,7 @@ Map supportedLtsFixtureEnv() { 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); } @@ -172,10 +177,15 @@ void nativeExecutablesAreConfinedToWingetArchive() throws Exception { @Test void wingetUsesDedicatedJreleaserDistribution() throws Exception { String config = Files.readString(MODULE_DIR.resolve("jreleaser.yml"), StandardCharsets.UTF_8); - - assertTrue(config.contains("camel-cli-winget:"), config); - assertTrue(config.contains("camel-launcher-{{projectVersion}}-winget-bin.zip"), config); - assertFalse(config.substring(config.indexOf("camel-cli:"), config.indexOf("camel-cli-winget:")) + int wingetStart = config.indexOf(" camel-cli-winget:"); + + assertTrue(wingetStart >= 0, config); + String wingetDistribution = config.substring(wingetStart); + assertTrue(wingetDistribution.contains("camel-launcher-{{projectVersion}}-winget-bin.zip"), config); + assertTrue(wingetDistribution.contains( + "https://archive.apache.org/dist/camel/apache-camel/{{projectVersion}}/{{artifactFile}}"), config); + assertFalse(wingetDistribution.contains("repo1.maven.org"), wingetDistribution); + assertFalse(config.substring(config.indexOf("camel-cli:"), wingetStart) .contains("winget:"), "the public distribution must not generate the WinGet package"); } @@ -222,6 +232,12 @@ void wingetInstallerManifestUsesLatestAcceptedSchema() throws Exception { assertTrue(winget.contains("winget-manifest.installer.1.12.0.schema.json"), winget); assertTrue(winget.contains("ManifestVersion: 1.12.0"), winget); + 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, winget.split("InstallerSha256: \\{\\{distributionChecksumSha256\\}\\}", -1).length - 1, + "both architecture entries must use the checksum of the same approved ZIP"); } private static final Path SCRIPT = Paths.get("src/jreleaser/bin/camel-package.sh"); @@ -264,9 +280,11 @@ private Map testModeEnvWithMvnStub(Path tmp, Path recordFile) th 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; } @@ -294,7 +312,9 @@ private Map envWithMvnStubProducingFormula(Path tmp, Path record 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; } @@ -313,7 +333,26 @@ private Map productionStyleEnvWithMvnStub(Path tmp, Path recordF StandardCharsets.UTF_8); assertTrue(mvnStub.toFile().setExecutable(true)); - return Map.of("PATH", stubDir + File.pathSeparator + System.getenv("PATH")); + 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; } private void addFailingCurlStub(Path tmp, Map env, Path curlRecordFile) throws IOException { @@ -481,6 +520,71 @@ void productionPrepareDoesNotPassAnEmptyMavenArgument(@TempDir Path tmp) throws 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"); From 6f4913ebbb357b1df1477b55f0bfd5764cf43a08 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:38:07 -0400 Subject: [PATCH 12/23] CAMEL-23703: Stage WinGet payload with Apache distributions Co-authored-by: Codex --- .../modules/ROOT/pages/release-guide.adoc | 29 +++- .../launcher/WingetDistroScriptsTest.java | 157 ++++++++++++++++++ etc/scripts/release-distro.sh | 34 +++- etc/scripts/stage-winget-distro.sh | 85 ++++++++++ 4 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java create mode 100755 etc/scripts/stage-winget-distro.sh diff --git a/docs/user-manual/modules/ROOT/pages/release-guide.adoc b/docs/user-manual/modules/ROOT/pages/release-guide.adoc index a4ae6bac35bfc..e64b4deb6c7b1 100644 --- a/docs/user-manual/modules/ROOT/pages/release-guide.adoc +++ b/docs/user-manual/modules/ROOT/pages/release-guide.adoc @@ -256,6 +256,19 @@ 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. + . Close the Apache staging repository: @@ -385,7 +398,21 @@ 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. + +* 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/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..56178e2c50dbf --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java @@ -0,0 +1,157 @@ +/* + * 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.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.HexFormat; +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.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"; + + @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); + } + + @Test + void releaseScriptExportsAndVerifiesTheApprovedCandidate() throws Exception { + String script = Files.readString(RELEASE_SCRIPT, StandardCharsets.UTF_8); + + assertTrue(script.contains("WINGET_CANDIDATE=${3:-}"), script); + assertTrue(script.contains("https://dist.apache.org/repos/dist/dev/camel/apache-camel/"), script); + assertTrue(script.contains("svn export"), script); + assertTrue(script.contains("WINGET_NAME=\"camel-launcher-${VERSION}-winget-bin.zip\""), script); + assertTrue(script.contains("for suffix in \"\" \".asc\" \".sha512\""), script); + assertTrue(script.contains("sha512sum -c"), script); + assertTrue(script.contains("gpg --verify"), script); + assertFalse(script.contains("mvn "), "promotion must not rebuild the approved WinGet payload"); + } + + 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/etc/scripts/release-distro.sh b/etc/scripts/release-distro.sh index 13d15171f7c0d..d0b96b16da0e5 100755 --- a/etc/scripts/release-distro.sh +++ b/etc/scripts/release-distro.sh @@ -18,17 +18,29 @@ VERSION=${1} DOWNLOAD=${2:-/tmp/camel-release} +WINGET_CANDIDATE=${3:-} 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]" 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 echo "################################################################################" echo " DOWNLOADING DISTRO FROM APACHE REPOSITORY " @@ -55,6 +67,26 @@ 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 + 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..17e982c931e61 --- /dev/null +++ b/etc/scripts/stage-winget-distro.sh @@ -0,0 +1,85 @@ +#!/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" +gpg --batch --verbose --armor --detach-sign \ + --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/" From b75a7a5ccec5fd1303521fc098c272f715031f80 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:24:21 -0400 Subject: [PATCH 13/23] CAMEL-23703: Build native executables during a real release The include-camel-exe and build-native-exe profiles activate on the camel.exe.build property, and the release profile sets it in . Maven's property activator only reads user and system properties, so a profile that sets a property cannot activate profiles keyed on it. Verified with help:active-profiles: under -Prelease alone neither profile is active. A release therefore built no native Windows executables and no WinGet payload at all, so the staging step in the release guide could never find camel-launcher--winget-bin.zip. Pass -Dcamel.exe.build=true as a real user property via maven-release-plugin's , and correct the two comments and the upgrade-guide sentence that claimed -Prelease was sufficient on its own. Co-Authored-By: Claude Opus 4.8 --- .../pages/camel-4x-upgrade-guide-4_22.adoc | 3 ++- pom.xml | 19 +++++++++++++++++-- tooling/camel-exe/pom.xml | 6 ++++-- 3 files changed, 23 insertions(+), 5 deletions(-) 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 cd7a6b4016fb6..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 @@ -101,7 +101,8 @@ 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`. +`-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 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/pom.xml b/tooling/camel-exe/pom.xml index 847e061bbcbb5..490e03e0be820 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 From 6bf28e18794060caa894d1869c48c5aa3f12d679 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:24:45 -0400 Subject: [PATCH 14/23] CAMEL-23703: Fix packaging defects found by running JReleaser Found by invoking jreleaser:config/prepare/package locally in dry-run, which nothing had done before: every test stubs mvn, so none of these were reachable by the existing suite. jreleaser.yml: the second project icon declared a url with no width or height. JReleaser rejects that, so jreleaser:config failed outright and camel-package.sh prepare died at its first JReleaser goal. Templates: JReleaser HTML-escapes double-brace placeholders, rewriting the "=" in the Homebrew redirector's "?filepath=" as "=" and producing a URL brew cannot download. Render URLs with the triple-brace unescaped form. Only Homebrew was affected today, but the other packagers use the same form so a future query parameter cannot silently break them. Scoop: drop autoupdate.hash. Maven Central publishes only a SHA-1 sidecar for these artifacts (.sha256 and .sha512 both 404), so pointing autoupdate at one would downgrade future versions from the pinned SHA-256 to SHA-1. Without it Scoop downloads the artifact and computes SHA-256 itself. camel-package.sh: a value-taking option with no value hit `shift 2` with one argument left, which fails under `set -e` and killed the script with a bare exit 1 and no message. Report the usage error instead. Treat a missing Homebrew formula directory on the LTS path as an error rather than skipping the rename, which would otherwise ship JReleaser's kebab-cased filename. Delete the test-mode block that rewrote the generated formula's url, version and sha256: it supported a camel-validate.sh that was never written, nothing invoked it, and it put network fetches and sed rewriting of a release artifact into the release path. camel.c: fall back to the module path when symlink resolution fails, so a directly-invoked exe still finds camel.bat instead of failing outright. Co-Authored-By: Claude Opus 4.8 --- dsl/camel-jbang/camel-launcher/README.md | 6 + dsl/camel-jbang/camel-launcher/jreleaser.yml | 5 + .../src/jreleaser/bin/camel-package.sh | 117 +++++------------- .../camel-cli/brew/formula.rb.tpl | 4 +- .../tools/chocolateyinstall.ps1.tpl | 2 +- .../camel-cli/scoop/manifest.json.tpl | 9 +- .../camel-cli/winget/installer.yaml.tpl | 4 +- tooling/camel-exe/src/main/native/camel.c | 11 +- 8 files changed, 57 insertions(+), 101 deletions(-) diff --git a/dsl/camel-jbang/camel-launcher/README.md b/dsl/camel-jbang/camel-launcher/README.md index 64432e8ccd4d6..23e782f3526fa 100644 --- a/dsl/camel-jbang/camel-launcher/README.md +++ b/dsl/camel-jbang/camel-launcher/README.md @@ -134,6 +134,12 @@ Both resolve to the same artifact — `search.maven.org/remotecontent` simply re 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 diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml b/dsl/camel-jbang/camel-launcher/jreleaser.yml index 8c3abf77adc91..89788505f4d67 100644 --- a/dsl/camel-jbang/camel-launcher/jreleaser.yml +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -78,7 +78,12 @@ project: 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 diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh index 34d32b51cb41e..ce773b18f3ccc 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -49,10 +49,17 @@ case "$SUBCOMMAND" in *) 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) CHANNEL="${2:-}"; shift 2 ;; - --lts-line) LTS_LINE="${2:-}"; shift 2 ;; + --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 @@ -301,94 +308,28 @@ mvn -B -ntp -f "$MODULE_DIR/pom.xml" \ # the filename needs fixing up here. if [ "$CHANNEL" = "lts" ]; then brew_formula_dir="$MODULE_DIR/target/jreleaser/package/camel-cli/brew/Formula" - if [ -d "$brew_formula_dir" ]; then - 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 + # `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 -fi - -# ---------------------------------------------------------------------------- -# TEST-MODE HACK: swap the Homebrew formula's download to a real, already-published -# camel-launcher release instead of this run's synthetic/SNAPSHOT-derived version. -# -# Every packager's downloadUrl (jreleaser.yml) points at the real Maven Central -# coordinates for {{projectVersion}}. That is correct in production - the version being -# released really is published there. In test mode, projectVersion is a synthetic -# placeholder (CAMEL_PACKAGE_TEST_VERSION) that is never actually published anywhere, so -# a real `brew install` always 404s at the download step. This was never exposed before -# because earlier Homebrew validation failures happened before validation reached the download. -# -# Rather than standing up a local HTTP mirror, we patch the *generated formula file* -# in place (never jreleaser.yml/formula.rb.tpl, which stay correct for real releases) so -# its url/version/sha256 describe a real, currently-published camel-launcher release. -# That lets `brew install`/`brew test` genuinely download, checksum-verify, install, and -# run a real artifact end-to-end in CI. camel-validate.sh's Homebrew validator reads the -# formula's own `version` line for its post-install assertion (rather than assuming the -# build's synthetic version), so it stays correct against whatever version this lands on. -# -# This intentionally does not exercise *this build's own* zip/tar.gz - camel-validate.sh's -# "local" archive check covers that separately, without going through any package manager. -if [ "${CAMEL_PACKAGE_TEST_MODE:-}" = "true" ] && [ -n "${CAMEL_PACKAGE_TEST_VERSION:-}" ]; then - brew_formula_dir="$MODULE_DIR/target/jreleaser/package/camel-cli/brew/Formula" - formula_file="$brew_formula_dir/$BREW_FORMULA.rb" - if [ -f "$formula_file" ]; then - command -v curl >/dev/null 2>&1 || { echo "Error: curl is required to patch the test-mode Homebrew formula." 1>&2; exit 1; } - - known_good_version=$(curl -fsSL "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/maven-metadata.xml" \ - | sed -n 's/.*\(.*\)<\/release>.*/\1/p') - if [ -z "$known_good_version" ]; then - echo "Error: could not resolve a known-good camel-launcher release version from Maven Central." 1>&2 - exit 1 - fi - - known_good_url="https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/$known_good_version/camel-launcher-$known_good_version-bin.zip" - known_good_download=$(mktemp) - if ! curl -fsSL -o "$known_good_download" "$known_good_url"; then - rm -f "$known_good_download" - echo "Error: could not download known-good artifact at $known_good_url" 1>&2 - exit 1 - fi - if command -v sha256sum >/dev/null 2>&1; then - known_good_sha256=$(sha256sum "$known_good_download" | awk '{print $1}') - elif command -v shasum >/dev/null 2>&1; then - known_good_sha256=$(shasum -a 256 "$known_good_download" | awk '{print $1}') - else - rm -f "$known_good_download" - echo "Error: sha256sum or shasum is required to hash the known-good artifact." 1>&2 + 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 - rm -f "$known_good_download" - if [ -z "$known_good_sha256" ]; then - echo "Error: could not compute sha256 for the known-good artifact." 1>&2 - exit 1 - fi - - # Also patches the `test do` block's assert_match: JReleaser bakes {{projectVersion}} - # in there too (the real POM SNAPSHOT version, per the note above the mvn invocation), - # so `brew test` would otherwise assert the wrong string against the real installed - # binary's actual `--version` output. - tmp_formula=$(mktemp) - sed -e "s#^\( url \).*#\1\"$known_good_url\"#" \ - -e "s#^\( version \).*#\1\"$known_good_version\"#" \ - -e "s#^\( sha256 \).*#\1\"$known_good_sha256\"#" \ - -e "s#assert_match \"[^\"]*\", output#assert_match \"$known_good_version\", output#" \ - "$formula_file" > "$tmp_formula" - mv "$tmp_formula" "$formula_file" - echo "TEST MODE: patched $formula_file to install real published camel-launcher $known_good_version (was synthetic $PROJECT_VERSION) - see the comment above this block for why." + 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 index 2035b3ecab805..1aa5aee96077b 100644 --- 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 @@ -22,8 +22,8 @@ require_relative "{{.}}" class {{brewFormulaName}} < Formula desc "{{projectDescription}}" - homepage "{{projectLinkHomepage}}" - url "{{distributionUrl}}"{{#brewDownloadStrategy}}, :using => {{.}}{{/brewDownloadStrategy}} + homepage "{{{projectLinkHomepage}}}" + url "{{{distributionUrl}}}"{{#brewDownloadStrategy}}, :using => {{.}}{{/brewDownloadStrategy}} version "{{projectVersion}}" sha256 "{{distributionChecksumSha256}}" license "{{projectLicense}}" 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 index 66c045b34c829..d377b36609cd8 100644 --- 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 @@ -23,7 +23,7 @@ $app_exe = Join-Path $app_home 'bin/{{distributionExecutableWindows}}' Install-ChocolateyZipPackage ` -PackageName '{{chocolateyPackageName}}' ` - -Url '{{distributionUrl}}' ` + -Url '{{{distributionUrl}}}' ` -Checksum '{{distributionChecksumSha256}}' ` -ChecksumType 'sha256' ` -UnzipLocation $package 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 index c8af564a8b614..a43488daba914 100644 --- 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 @@ -1,9 +1,9 @@ { "version": "{{projectVersion}}", "description": "{{projectDescription}}", - "homepage": "{{projectLinkHomepage}}", + "homepage": "{{{projectLinkHomepage}}}", "license": "{{projectLicense}}", - "url": "{{distributionUrl}}", + "url": "{{{distributionUrl}}}", "hash": "sha256:{{distributionChecksumSha256}}", "extract_dir": "{{distributionArtifactRootEntryName}}", "bin": "bin\\{{distributionExecutableWindows}}", @@ -19,9 +19,6 @@ }, "autoupdate": { "url": "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher/$version/camel-launcher-$version-bin.zip", - "extract_dir": "camel-launcher-$version", - "hash": { - "url": "$url.sha1" - } + "extract_dir": "camel-launcher-$version" } } 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 index 5e909ff8e12d2..460494ceb6fd6 100644 --- 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 @@ -30,7 +30,7 @@ PackageVersion: {{wingetPackageVersion}} ReleaseDate: {{wingetReleaseDate}} Installers: - Architecture: x64 - InstallerUrl: {{distributionUrl}} + InstallerUrl: {{{distributionUrl}}} InstallerSha256: {{distributionChecksumSha256}} InstallerType: zip NestedInstallerType: portable @@ -60,7 +60,7 @@ Installers: {{/wingetHasPackageDependencies}} {{/wingetHasDependencies}} - Architecture: arm64 - InstallerUrl: {{distributionUrl}} + InstallerUrl: {{{distributionUrl}}} InstallerSha256: {{distributionChecksumSha256}} InstallerType: zip NestedInstallerType: portable diff --git a/tooling/camel-exe/src/main/native/camel.c b/tooling/camel-exe/src/main/native/camel.c index e57d441df6d19..af3cb69384edb 100644 --- a/tooling/camel-exe/src/main/native/camel.c +++ b/tooling/camel-exe/src/main/native/camel.c @@ -124,10 +124,17 @@ static wchar_t *get_exe_dir(void) { 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); - free(modulePath); if (buf == NULL) { - return NULL; + buf = modulePath; + } else { + free(modulePath); } wchar_t *slash = wcsrchr(buf, L'\\'); if (slash == NULL) { From d75c95664fe975e231fd85241c1c9fbcad2b0cb8 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:24:49 -0400 Subject: [PATCH 15/23] CAMEL-23703: Require the voted digest to promote the WinGet candidate Every dist/dev candidate carries its own self-consistent .sha512 and .asc, so exporting rc3 when the vote approved rc4 passed both checks and silently promoted a superseded payload to dist/release. The candidate number cannot distinguish them; only the digest published with the vote can. release-distro.sh now takes the approved SHA-512 as a fourth argument, mandatory whenever a candidate number is given, and aborts if the exported ZIP does not match. The check runs before the sidecar and signature verification so the error names the real problem rather than sending the release manager after a signature that was never broken. stage-winget-distro.sh prints the digest for the vote email, and honours CAMEL_GPG_KEY for release managers holding more than one key (the equivalent of maven-gpg-plugin's gpg.keyname). The workflow's Maven-exclusion gate matched only the ZIP and its .sha1; match every sidecar so a leaked .asc or .md5 is caught too. WingetDistroScriptsTest now drives release-distro.sh against stubbed wget/svn/gpg rather than asserting on its source text, covering promotion of the approved candidate, a tampered payload, a superseded candidate, and a missing digest. Co-Authored-By: Claude Opus 4.8 --- .../workflows/camel-launcher-native-exe.yml | 5 +- .../modules/ROOT/pages/release-guide.adoc | 9 +- .../launcher/WingetDistroScriptsTest.java | 185 ++++++++++++++++-- etc/scripts/release-distro.sh | 29 ++- etc/scripts/stage-winget-distro.sh | 14 +- 5 files changed, 225 insertions(+), 17 deletions(-) diff --git a/.github/workflows/camel-launcher-native-exe.yml b/.github/workflows/camel-launcher-native-exe.yml index 4cc50b8ee213f..bfdfba5d683cc 100644 --- a/.github/workflows/camel-launcher-native-exe.yml +++ b/.github/workflows/camel-launcher-native-exe.yml @@ -222,8 +222,9 @@ jobs: -Dcamel.exe.build=true -DskipTests \ -DaltSnapshotDeploymentRepository="winget-check::file://$MAVEN_REPO" - DEPLOYED_WINGET_FILES=$(find "$MAVEN_REPO" -type f \ - \( -name '*-winget-bin.zip' -o -name '*-winget-bin.zip.sha1' \) -print) + # 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" diff --git a/docs/user-manual/modules/ROOT/pages/release-guide.adoc b/docs/user-manual/modules/ROOT/pages/release-guide.adoc index e64b4deb6c7b1..1eb3a1cf7b563 100644 --- a/docs/user-manual/modules/ROOT/pages/release-guide.adoc +++ b/docs/user-manual/modules/ROOT/pages/release-guide.adoc @@ -269,6 +269,10 @@ candidate with the same candidate number used in the vote: 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: @@ -398,12 +402,15 @@ 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 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 index 56178e2c50dbf..94b5652238171 100644 --- 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 @@ -17,12 +17,15 @@ 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; @@ -30,6 +33,7 @@ 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 { @@ -39,6 +43,7 @@ class WingetDistroScriptsTest { 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 { @@ -136,18 +141,176 @@ void stageScriptRejectsCandidateZero(@TempDir Path tmp) throws Exception { 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 releaseScriptExportsAndVerifiesTheApprovedCandidate() throws Exception { - String script = Files.readString(RELEASE_SCRIPT, StandardCharsets.UTF_8); - - assertTrue(script.contains("WINGET_CANDIDATE=${3:-}"), script); - assertTrue(script.contains("https://dist.apache.org/repos/dist/dev/camel/apache-camel/"), script); - assertTrue(script.contains("svn export"), script); - assertTrue(script.contains("WINGET_NAME=\"camel-launcher-${VERSION}-winget-bin.zip\""), script); - assertTrue(script.contains("for suffix in \"\" \".asc\" \".sha512\""), script); - assertTrue(script.contains("sha512sum -c"), script); - assertTrue(script.contains("gpg --verify"), script); - assertFalse(script.contains("mvn "), "promotion must not rebuild the approved WinGet payload"); + 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 { diff --git a/etc/scripts/release-distro.sh b/etc/scripts/release-distro.sh index d0b96b16da0e5..a6a68722896cd 100755 --- a/etc/scripts/release-distro.sh +++ b/etc/scripts/release-distro.sh @@ -19,16 +19,17 @@ 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/" +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] [winget-candidate]" + echo "Usage: release-distro.sh [temp-directory] [winget-candidate] [winget-sha512]" exit 1 fi case "${WINGET_CANDIDATE}" in @@ -41,6 +42,22 @@ 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 " @@ -77,6 +94,14 @@ if [ -n "${WINGET_CANDIDATE}" ]; then 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 diff --git a/etc/scripts/stage-winget-distro.sh b/etc/scripts/stage-winget-distro.sh index 17e982c931e61..c96087f97a344 100755 --- a/etc/scripts/stage-winget-distro.sh +++ b/etc/scripts/stage-winget-distro.sh @@ -67,7 +67,14 @@ mkdir -p "$WORK_DIR" svn checkout --depth immediates "$DIST_DEV_REPO" "$SVN_DIR" mkdir "$CANDIDATE_DIR" cp -p "$ZIP" "$CANDIDATE_DIR/$FILE_NAME" -gpg --batch --verbose --armor --detach-sign \ +# 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" @@ -83,3 +90,8 @@ 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")" From 5dbd7ecf20227c2bb82f881d8afa4bc4a4ebfc21 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:25:05 -0400 Subject: [PATCH 16/23] CAMEL-23703: Share one content definition between both launcher archives bin.xml and winget-bin.xml were near-duplicates differing only by the tar.gz format and one fileSet. The two archives must deliver the same CLI, so a file added to one could silently go missing from the other. Move the shared dependencySet and fileSets into launcher-content.xml and reference it from both descriptors, leaving each with only its id, formats and, for WinGet, the native executables. Verified behaviour-preserving: the public archive's entry list is byte-identical before and after, and the WinGet archive differs from it by exactly bin/camel-x64.exe and bin/camel-arm64.exe. PackagePlanTest: replace assertions that matched substrings in pom.xml, jreleaser.yml, the assembly descriptors and a GitHub workflow with structural XML, YAML and JSON parsing, so reformatting cannot break a test while a real content change slips through. Includes resolve through the shared component, so they describe what each archive carries rather than which file declares it. Drop the assertion that compared two hardcoded dates, which could never fail, in favour of running the wrapper against every line the production allowlist advertises: that starts failing when an LTS line's support window lapses, which is the intended signal. Drop the test asserting the workflow file's contents and the one asserting the wrapper's shebang; CI running is the verification for the former, and fifteen execution tests cover the latter. Remove the agent planning and design documents committed under docs/, which is the Antora source tree and not a home for working notes. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-19-winget-non-maven-payload.md | 964 ------------------ ...6-07-19-winget-non-maven-payload-design.md | 284 ------ .../camel-launcher/src/main/assembly/bin.xml | 56 +- .../src/main/assembly/launcher-content.xml | 78 ++ .../src/main/assembly/winget-bin.xml | 55 +- .../dsl/jbang/launcher/PackagePlanTest.java | 360 +++++-- 6 files changed, 356 insertions(+), 1441 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-19-winget-non-maven-payload.md delete mode 100644 docs/superpowers/specs/2026-07-19-winget-non-maven-payload-design.md create mode 100644 dsl/camel-jbang/camel-launcher/src/main/assembly/launcher-content.xml diff --git a/docs/superpowers/plans/2026-07-19-winget-non-maven-payload.md b/docs/superpowers/plans/2026-07-19-winget-non-maven-payload.md deleted file mode 100644 index 5f5b54c3d3025..0000000000000 --- a/docs/superpowers/plans/2026-07-19-winget-non-maven-payload.md +++ /dev/null @@ -1,964 +0,0 @@ -# WinGet Non-Maven Payload Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build the WinGet launcher ZIP reproducibly from `tooling/camel-exe` without attaching or publishing that ZIP as a Maven artifact, then vote on and publish the exact ZIP through the Apache distribution archive. - -**Architecture:** Keep the current `maven-dependency-plugin:copy` and Maven Assembly Plugin flow, but set the WinGet assembly execution to `attach=false`. Add CI gates for byte reproducibility and Maven repository exclusion, stage the local ZIP with its Apache signature and SHA-512 checksum under `dist/dev`, promote the approved files unchanged to `dist/release`, and make JReleaser verify the archived bytes before generating WinGet packaging output. - -**Tech Stack:** Maven 3.9.12+, Maven Assembly Plugin 3.8.0, Maven Dependency Plugin, JReleaser 1.25.0, Bash, JUnit 5, llvm-mingw 20260616, GitHub Actions, Apache Subversion distribution repositories. - -## Global Constraints - -- Do not create a custom Windows installer. -- Do not add dependencies. -- Keep `camel-x64.exe` and `camel-arm64.exe` out of the standard `-bin.zip` and `-bin.tar.gz` archives. -- Build both native executables from `tooling/camel-exe` with llvm-mingw version `20260616` and its pinned SHA-256. -- Build `camel-launcher--winget-bin.zip` only with Maven Assembly Plugin 3.8.0 and `project.build.outputTimestamp`. -- Do not attach, install, or deploy the WinGet ZIP through Maven. Maven must not create a WinGet ZIP `.sha1` sidecar. -- The Apache distribution staging step, not Maven, creates the WinGet ZIP `.asc` and `.sha512` files. -- Promote the approved `dist/dev` ZIP, signature, and checksum without rebuilding or repacking them. -- Point WinGet manifests at `https://archive.apache.org/dist/camel/apache-camel//camel-launcher--winget-bin.zip`. -- Treat missing artifacts, checksum mismatches, signature failures, unavailable archive URLs, and reproducibility differences as hard failures. -- Keep Scoop's existing Maven Central `.sha1` behavior unchanged because it applies to the standard launcher archive. -- Use American English and do not introduce em dashes in code comments or documentation. -- Before every `git add`, inspect the exact diff and scan it for secret-shaped content. -- Every implementation commit uses a `CAMEL-23703:` subject and the trailer `Co-authored-by: Codex `. - ---- - -## File Map - -- `dsl/camel-jbang/camel-launcher/pom.xml`: builds the local, non-attached WinGet ZIP. -- `dsl/camel-jbang/camel-launcher/src/main/assembly/winget-bin.xml`: remains the single definition of WinGet ZIP contents. -- `dsl/camel-jbang/camel-launcher/jreleaser.yml`: points only the WinGet distribution at the Apache archive. -- `dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh`: verifies that the local WinGet ZIP exactly matches the archived ZIP before JReleaser runs. -- `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java`: verifies the Maven, workflow, JReleaser, and package-script contracts. -- `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java`: exercises candidate staging outputs and verifies promotion-script requirements. -- `.github/workflows/camel-launcher-native-exe.yml`: builds twice, compares hashes, deploys to a temporary Maven repository, and rejects a deployed WinGet artifact. -- `etc/scripts/stage-winget-distro.sh`: signs, checksums, and prepares the local `dist/dev` candidate working copy without committing it. -- `etc/scripts/release-distro.sh`: imports the approved candidate files into the existing `dist/release` working copy and verifies them first. -- `dsl/camel-jbang/camel-launcher/README.md`: documents the local-only Maven artifact and Apache archive URL. -- `tooling/camel-exe/README.md`: documents the reproducibility gate. -- `docs/user-manual/modules/ROOT/pages/release-guide.adoc`: documents pre-vote staging, vote evidence, post-vote promotion, and archive synchronization. - -### Task 1: Make the Maven Assembly Local-Only - -**Files:** -- Modify: `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java:137-161` -- Modify: `dsl/camel-jbang/camel-launcher/pom.xml:413-424` -- Modify: `dsl/camel-jbang/camel-launcher/README.md:11-30` - -**Interfaces:** -- Consumes: the existing `include-camel-exe` profile, `camel-exe` artifacts with type `exe` and classifiers `x64` and `arm64`, and `src/main/assembly/winget-bin.xml`. -- Produces: `target/camel-launcher-${project.version}-winget-bin.zip` during `package`, while leaving `project.getAttachedArtifacts()` unchanged. - -- [ ] **Step 1: Extend the existing packaging contract test** - -Replace the final two assertions in `nativeExecutablesAreConfinedToWingetArchive()` with an execution-scoped check so another assembly execution cannot satisfy the test accidentally: - -```java - String nativeProfilePom = pom.substring(nativeProfile); - int wingetExecutionStart = nativeProfilePom.indexOf("assemble-winget-bin"); - int wingetExecutionEnd = nativeProfilePom.indexOf("", wingetExecutionStart); - String wingetExecution = nativeProfilePom.substring(wingetExecutionStart, wingetExecutionEnd); - - assertTrue(wingetExecutionStart >= 0, "the native executable profile must create the WinGet archive"); - assertTrue(wingetExecution.contains("src/main/assembly/winget-bin.xml"), - wingetExecution); - assertTrue(wingetExecution.contains("false"), - "the WinGet payload must remain a local release file, not an attached Maven artifact"); -``` - -- [ ] **Step 2: Run the focused test and confirm the intended failure** - -Run: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn -Dtest=PackagePlanTest#nativeExecutablesAreConfinedToWingetArchive test -``` - -Expected: FAIL because the `assemble-winget-bin` execution does not contain `false`. - -- [ ] **Step 3: Disable attachment only for the WinGet assembly** - -Add `attach` beside the existing descriptor configuration in `pom.xml`: - -```xml - - false - - src/main/assembly/winget-bin.xml - - -``` - -Do not change the standard launcher assemblies or the attached `camel-exe` classifier artifacts. The launcher still consumes those classifier artifacts through `maven-dependency-plugin:copy`. - -- [ ] **Step 4: Document the Maven lifecycle result** - -Replace the WinGet build paragraph in `dsl/camel-jbang/camel-launcher/README.md` with: - -```markdown -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, absent from the public archives, and present in the WinGet ZIP: -``` - -- [ ] **Step 5: Run the focused test and package check** - -Run: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn -Dtest=PackagePlanTest#nativeExecutablesAreConfinedToWingetArchive test -``` - -Expected: PASS with one test run and no failures. - -The native build itself is covered in Task 2 because it requires the pinned llvm-mingw toolchain. - -- [ ] **Step 6: Review, scan, and commit** - -Run: - -```bash -git diff -- dsl/camel-jbang/camel-launcher/pom.xml dsl/camel-jbang/camel-launcher/README.md dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java -git diff --check -git diff | rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,})' -``` - -Expected: the first command shows only this task's changes, `git diff --check` is silent, and the secret scan is silent. Then run: - -```bash -git add dsl/camel-jbang/camel-launcher/pom.xml \ - dsl/camel-jbang/camel-launcher/README.md \ - dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java -git commit -m "CAMEL-23703: Keep WinGet payload out of Maven repositories" \ - -m "Co-authored-by: Codex " -``` - -### Task 2: Prove Reproducibility and Maven Repository Exclusion - -**Files:** -- Modify: `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java:163-172` -- Modify: `.github/workflows/camel-launcher-native-exe.yml:188-202` -- Modify: `tooling/camel-exe/README.md:37-48` - -**Interfaces:** -- Consumes: the local-only ZIP from Task 1, the pinned llvm-mingw setup action, and the existing three-module native launcher build. -- Produces: a CI gate that compares SHA-256 values across two clean builds and rejects any WinGet ZIP or WinGet ZIP `.sha1` in a temporary Maven deployment repository. - -- [ ] **Step 1: Add a workflow contract test** - -Add this method to `PackagePlanTest`: - -```java - @Test - void nativeWorkflowChecksReproducibilityAndMavenExclusion() throws Exception { - String workflow = Files.readString( - MODULE_DIR.resolve("../../../.github/workflows/camel-launcher-native-exe.yml").normalize(), - StandardCharsets.UTF_8); - - assertTrue(workflow.contains("-name 'camel-launcher-*-winget-bin.zip'"), workflow); - assertTrue(workflow.contains("sha256sum --check \"$HASH_FILE\""), workflow); - assertTrue(workflow.contains("-DaltSnapshotDeploymentRepository=winget-check::file://$MAVEN_REPO"), workflow); - assertTrue(workflow.contains("-name '*-winget-bin.zip'"), workflow); - assertTrue(workflow.contains("-name '*-winget-bin.zip.sha1'"), workflow); - } -``` - -- [ ] **Step 2: Run the new test and confirm it fails** - -Run: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn -Dtest=PackagePlanTest#nativeWorkflowChecksReproducibilityAndMavenExclusion test -``` - -Expected: FAIL because the workflow has no second-build hash comparison or temporary deployment check, and its current ZIP glob is not WinGet-specific. - -- [ ] **Step 3: Make the archive assertion unambiguous** - -Change the ZIP lookup in `.github/workflows/camel-launcher-native-exe.yml` to: - -```yaml - ZIP=$(find dsl/camel-jbang/camel-launcher/target -maxdepth 1 -name 'camel-launcher-*-winget-bin.zip' -print -quit) -``` - -- [ ] **Step 4: Add the clean rebuild checksum gate** - -Insert this step after the archive-content assertion: - -```yaml - - 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" - - mvn -P apache-snapshots \ - -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ - -Dcamel.exe.build=true -DskipTests - - sha256sum --check "$HASH_FILE" -``` - -The stored paths are repository-relative, so the second clean build recreates files at the exact paths recorded by `sha256sum`. - -- [ ] **Step 5: Add the temporary Maven deployment gate** - -Insert this step after the reproducibility step: - -```yaml - - name: Verify WinGet archive is not deployed by Maven - run: | - MAVEN_REPO="$RUNNER_TEMP/winget-maven-repo" - mvn -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 - - DEPLOYED_WINGET_FILES=$(find "$MAVEN_REPO" -type f \ - \( -name '*-winget-bin.zip' -o -name '*-winget-bin.zip.sha1' \) -print) - if [ -n "$DEPLOYED_WINGET_FILES" ]; then - echo "ERROR: Maven deployed the local-only WinGet payload:" - echo "$DEPLOYED_WINGET_FILES" - exit 1 - fi -``` - -Do not accept the absence of only `.sha512` as proof. This gate must specifically reject the ZIP and the Maven repository's `.sha1` sidecar. - -- [ ] **Step 6: Document the reproducibility gate** - -Append this paragraph after the release-build command in `tooling/camel-exe/README.md`: - -```markdown -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. -``` - -- [ ] **Step 7: Run the focused contract test and local syntax checks** - -Run: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn -Dtest=PackagePlanTest#nativeWorkflowChecksReproducibilityAndMavenExclusion test -cd ../../.. -git diff --check -``` - -Expected: the JUnit test passes and `git diff --check` is silent. Do not report the native binaries as reproducible until the GitHub Actions job, or the equivalent pinned-toolchain command, completes both builds successfully. - -- [ ] **Step 8: Review, scan, and commit** - -Run: - -```bash -git diff -- .github/workflows/camel-launcher-native-exe.yml tooling/camel-exe/README.md dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java -git diff | rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,})' -``` - -Expected: only this task's changes appear and the secret scan is silent. Then run: - -```bash -git add .github/workflows/camel-launcher-native-exe.yml \ - tooling/camel-exe/README.md \ - dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java -git commit -m "CAMEL-23703: Verify reproducible WinGet packaging" \ - -m "Co-authored-by: Codex " -``` - -### Task 3: Use and Verify the Archived Apache Payload - -**Files:** -- Modify: `dsl/camel-jbang/camel-launcher/jreleaser.yml:139-155` -- Modify: `dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh:147-214` -- Modify: `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java:41-72,163-171,236-305,412-555` -- Modify: `dsl/camel-jbang/camel-launcher/README.md:107-141` - -**Interfaces:** -- Consumes: local `target/camel-launcher--winget-bin.zip` and the immutable Apache archive URL for the same version and filename. -- Produces: JReleaser WinGet output only after `cmp` proves that the local ZIP and archived ZIP are byte-identical. - -- [ ] **Step 1: Add the archive URL assertions** - -Replace `wingetUsesDedicatedJreleaserDistribution()` with: - -```java - @Test - void wingetUsesDedicatedJreleaserDistribution() throws Exception { - String config = Files.readString(MODULE_DIR.resolve("jreleaser.yml"), StandardCharsets.UTF_8); - int wingetStart = config.indexOf(" camel-cli-winget:"); - String wingetDistribution = config.substring(wingetStart); - - assertTrue(wingetStart >= 0, config); - assertTrue(wingetDistribution.contains("camel-launcher-{{projectVersion}}-winget-bin.zip"), config); - assertTrue(wingetDistribution.contains( - "https://archive.apache.org/dist/camel/apache-camel/{{projectVersion}}/{{artifactFile}}"), config); - assertFalse(wingetDistribution.contains("repo1.maven.org"), wingetDistribution); - assertFalse(config.substring(config.indexOf("camel-cli:"), wingetStart).contains("winget:"), - "the public distribution must not generate the WinGet package"); - } -``` - -- [ ] **Step 2: Add deterministic local and remote ZIP fixtures** - -Extend `cleanupFixtures()` with: - -```java - Files.deleteIfExists(MODULE_DIR.resolve("target/camel-launcher-" + TEST_VERSION + "-winget-bin.zip")); -``` - -Add this helper below `writeReleaseFixture`: - -```java - Path writeWingetFixture(String content) throws IOException { - return writeReleaseFixture("-winget-bin.zip", content); - } -``` - -In both `testModeEnvWithMvnStub` and `envWithMvnStubProducingFormula`, create an identical remote source and add the guarded test-only variable: - -```java - Path winget = writeWingetFixture("fixture-winget"); - env.put("CAMEL_PACKAGE_TEST_WINGET_REMOTE", winget.toString()); -``` - -Change `productionStyleEnvWithMvnStub` to return a mutable `LinkedHashMap`, create the local fixture, and install a successful `curl` stub: - -```java - 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; -``` - -Keep the existing `mvn` stub in that method unchanged. Do not use a test variable in this production-style helper, because this path must exercise the real `curl` branch. - -- [ ] **Step 3: Add byte-match, mismatch, and guard tests** - -Add these methods to `PackagePlanTest`: - -```java - @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)); - } -``` - -Also add `writeWingetFixture("fixture-winget")` before invoking the script in any happy-path test whose environment helper does not create it. Preserve `scoopAutoupdateUsesPublishedMavenCentralSha1Sidecar()` unchanged. - -Extend `wingetInstallerManifestUsesLatestAcceptedSchema()` so the template continues to bind both architectures and both installer checksums to this one distribution ZIP: - -```java - 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, winget.split("InstallerSha256: \\{\\{distributionChecksumSha256\\}\\}", -1).length - 1, - "both architecture entries must use the checksum of the same approved ZIP"); -``` - -- [ ] **Step 4: Run the new tests and confirm they fail for the intended reasons** - -Run: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn -Dtest=PackagePlanTest#wingetUsesDedicatedJreleaserDistribution+productionPrepareVerifiesTheArchivedWingetPayload+prepareRejectsAnArchivedWingetPayloadWithDifferentBytes+wingetRemoteOverrideRequiresExplicitTestMode+missingWingetPayloadFailsBeforeJReleaser+wingetInstallerManifestUsesLatestAcceptedSchema test -``` - -Expected: FAIL because JReleaser still uses Maven Central and `camel-package.sh` neither locates nor verifies the WinGet ZIP. - -- [ ] **Step 5: Point only WinGet at the Apache archive** - -Change the `camel-cli-winget` block in `jreleaser.yml` to: - -```yaml - winget: - active: RELEASE - downloadUrl: "https://archive.apache.org/dist/camel/apache-camel/{{projectVersion}}/{{artifactFile}}" -``` - -Do not change the Maven Central URLs for Homebrew, SDKMAN, Scoop, or Chocolatey. - -- [ ] **Step 6: Verify the archived bytes before any staging or JReleaser work** - -Immediately after the snapshot guard in `camel-package.sh`, reject an unguarded test override: - -```bash -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 -``` - -Add the WinGet paths beside `TAR` and `ZIP`: - -```bash -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")" -``` - -Add this check after the standard ZIP check and before website staging: - -```bash -if [ ! -f "$WINGET_ZIP" ]; then - echo "Error: WinGet release ZIP not found: $WINGET_ZIP" 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 -``` - -This comparison must happen before creating the website staging directory and before invoking Maven/JReleaser. Do not regenerate the ZIP after a mismatch. - -- [ ] **Step 7: Document the distinct URL rules** - -Replace the sentence that says all other packagers use Maven Central in `dsl/camel-jbang/camel-launcher/README.md` with: - -```markdown -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. -``` - -Append this sentence to the native bootstrap subsection: - -```markdown -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. -``` - -- [ ] **Step 8: Run all package-plan tests** - -Run: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn -Dtest=PackagePlanTest test -``` - -Expected: all `PackagePlanTest` tests pass. A test that deliberately supplies different local and remote bytes must fail inside the script and pass at the JUnit assertion level. - -- [ ] **Step 9: Review, scan, and commit** - -Run: - -```bash -git diff -- dsl/camel-jbang/camel-launcher/jreleaser.yml \ - dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh \ - dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java \ - dsl/camel-jbang/camel-launcher/README.md -git diff --check -git diff | rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,})' -``` - -Expected: only archive-verification changes appear, whitespace checks are silent, and the secret scan is silent. Then run: - -```bash -git add dsl/camel-jbang/camel-launcher/jreleaser.yml \ - dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh \ - dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/PackagePlanTest.java \ - dsl/camel-jbang/camel-launcher/README.md -git commit -m "CAMEL-23703: Verify archived WinGet payload bytes" \ - -m "Co-authored-by: Codex " -``` - -### Task 4: Stage and Promote the Approved Apache Distribution Files - -**Files:** -- Create: `etc/scripts/stage-winget-distro.sh` -- Modify: `etc/scripts/release-distro.sh:18-75` -- Create: `dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java` -- Modify: `docs/user-manual/modules/ROOT/pages/release-guide.adoc:254-278,370-398` - -**Interfaces:** -- Consumes: `stage-winget-distro.sh [work-directory]` and `release-distro.sh [temp-directory] [winget-candidate]`. -- Produces: an uncommitted `dist/dev` working copy containing the ZIP, `.asc`, and `.sha512`, followed after the vote by an uncommitted `dist/release` working copy containing the same verified bytes. - -- [ ] **Step 1: Add a real staging-output test** - -Create `WingetDistroScriptsTest.java` with the ASF license header and this class body: - -```java -package org.apache.camel.dsl.jbang.launcher; - -import java.io.File; -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.HexFormat; -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.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"; - - @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 releaseScriptExportsAndVerifiesTheApprovedCandidate() throws Exception { - String script = Files.readString(RELEASE_SCRIPT, StandardCharsets.UTF_8); - - assertTrue(script.contains("WINGET_CANDIDATE=${3:-}"), script); - assertTrue(script.contains("https://dist.apache.org/repos/dist/dev/camel/apache-camel/"), script); - assertTrue(script.contains("svn export"), script); - assertTrue(script.contains("WINGET_NAME=\"camel-launcher-${VERSION}-winget-bin.zip\""), script); - assertTrue(script.contains("for suffix in \"\" \".asc\" \".sha512\""), script); - assertTrue(script.contains("sha512sum -c"), script); - assertTrue(script.contains("gpg --verify"), script); - assertFalse(script.contains("mvn "), "promotion must not rebuild the approved WinGet payload"); - } - - private static void writeExecutable(Path path, String content) throws Exception { - Files.writeString(path, content, StandardCharsets.UTF_8); - assertTrue(path.toFile().setExecutable(true)); - } -} -``` - -- [ ] **Step 2: Run the tests and confirm both implementation-dependent tests fail** - -Run: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn -Dtest=WingetDistroScriptsTest test -``` - -Expected: FAIL because `stage-winget-distro.sh` does not exist and `release-distro.sh` has no candidate export or verification logic. - -- [ ] **Step 3: Add the focused candidate staging script** - -Create `etc/scripts/stage-winget-distro.sh` with the ASF license header followed by: - -```bash -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" -gpg --batch --verbose --armor --detach-sign \ - --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/" -``` - -Make the script executable with `chmod +x etc/scripts/stage-winget-distro.sh`. This is a file-mode change, not a content rewrite. - -- [ ] **Step 4: Import and verify the voted candidate during release promotion** - -Add these variables near the top of `etc/scripts/release-distro.sh`: - -```bash -WINGET_CANDIDATE=${3:-} -DIST_DEV_REPO="https://dist.apache.org/repos/dist/dev/camel/apache-camel" -``` - -Change the usage validation to accept and validate the optional positive candidate number: - -```bash -if [ -z "${VERSION}" -o ! -d "${DOWNLOAD}" ]; then - echo "Usage: release-distro.sh [temp-directory] [winget-candidate]" - 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 -``` - -Immediately after the existing loop that creates Maven distribution `.sha512` files, add: - -```bash -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 - 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 -``` - -Do not invoke Maven, Assembly Plugin, `zip`, or any repacking command in this promotion path. The existing wildcard copy then places all three exported files into the local `dist/release` working copy. - -- [ ] **Step 5: Document the pre-vote candidate flow** - -After the main Camel `release:perform` command in `release-guide.adoc`, add: - -```adoc -. 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. -``` - -- [ ] **Step 6: Document post-vote promotion and archive availability** - -Change the existing release distribution command to: - -```adoc - ./release-distro.sh -``` - -Add below it: - -```adoc -* 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. - -* 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" -``` - -- [ ] **Step 7: Run script behavior, syntax, and package tests** - -Run: - -```bash -bash -n etc/scripts/stage-winget-distro.sh -bash -n etc/scripts/release-distro.sh -cd dsl/camel-jbang/camel-launcher -mvn -Dtest=WingetDistroScriptsTest,PackagePlanTest test -``` - -Expected: both Bash syntax checks return zero. The staging test verifies the exact ZIP bytes, detached signature file, SHA-512 content, local SVN add, and absence of a commit. All package-plan tests pass. - -- [ ] **Step 8: Review, scan, and commit** - -Run: - -```bash -git diff -- etc/scripts/stage-winget-distro.sh etc/scripts/release-distro.sh \ - dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java \ - docs/user-manual/modules/ROOT/pages/release-guide.adoc -git diff --check -git diff | rg -n '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{20,})' -``` - -Expected: only candidate staging, promotion, tests, and release documentation appear. Whitespace and secret scans are silent. Then run: - -```bash -git add etc/scripts/stage-winget-distro.sh etc/scripts/release-distro.sh \ - dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WingetDistroScriptsTest.java \ - docs/user-manual/modules/ROOT/pages/release-guide.adoc -git commit -m "CAMEL-23703: Stage WinGet payload with Apache distributions" \ - -m "Co-authored-by: Codex " -``` - -## Final Verification Gate - -- [ ] Run the launcher module's complete test suite from its module directory: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn verify -``` - -Expected: BUILD SUCCESS with no skipped or excluded test failures. - -- [ ] Apply the module's normal formatting and generated-file check: - -```bash -cd dsl/camel-jbang/camel-launcher -mvn -DskipTests install -``` - -Expected: BUILD SUCCESS and `git status --short` shows no unexpected generated changes. - -- [ ] Run the native release gate with the pinned llvm-mingw toolchain available: - -```bash -cd ../../.. -mvn -P apache-snapshots \ - -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ - -Dcamel.exe.build=true -DskipTests -``` - -Expected: BUILD SUCCESS, both executables are present only in the WinGet ZIP, and the standard ZIP and TAR remain free of native executables. If the pinned compiler is unavailable on the current machine, report this command as unverified and require the GitHub Actions `camel-launcher-native` job to supply the evidence. - -- [ ] Verify the final history and worktree: - -```bash -git diff --check -git status --short --branch -git log --oneline -6 -``` - -Expected: no whitespace errors, no unintended uncommitted files, and four focused implementation commits after the approved design and plan commits. diff --git a/docs/superpowers/specs/2026-07-19-winget-non-maven-payload-design.md b/docs/superpowers/specs/2026-07-19-winget-non-maven-payload-design.md deleted file mode 100644 index 921ac5f71d693..0000000000000 --- a/docs/superpowers/specs/2026-07-19-winget-non-maven-payload-design.md +++ /dev/null @@ -1,284 +0,0 @@ -# CAMEL-23703: Non-Maven WinGet Payload Design - -Date: 2026-07-19 - -## Context - -The WinGet manifest needs a ZIP containing the Camel launcher distribution plus the native -`camel-x64.exe` and `camel-arm64.exe` bootstraps. The current build creates -`camel-launcher--winget-bin.zip` as an attached Maven assembly. Attaching the assembly -causes Maven's `install` and `deploy` phases to publish it as an additional `camel-launcher` -artifact with the Maven repository's `.sha1` checksum. - -The ZIP is a WinGet installer payload, not a Java dependency. It should therefore remain part of -the Apache Camel release while no longer being published to Maven Central. - -## Decision - -Generate the WinGet ZIP during the release candidate build with the existing Maven dependency and -assembly plugins, but configure the WinGet assembly with `attach=false`. Stage, vote on, sign, and -publish the generated ZIP through the Apache release distribution instead of Maven Central. - -JReleaser will continue to read the local ZIP to generate the WinGet manifest and its SHA-256 -checksum. The manifest will reference the immutable archived Apache release URL. - -## Goals - -- Keep `camel-launcher--winget-bin.zip` out of Maven Central. -- Preserve WinGet's ZIP-based portable installation and architecture-specific command selection. -- Build the ZIP from the normal launcher output and the native executables produced by - `tooling/camel-exe`. -- Produce byte-for-byte reproducible native executables and WinGet ZIPs from the same released - source, toolchain, and build instructions. -- Vote on and promote the exact bytes that WinGet later downloads. -- Fail the release when required artifacts, signatures, checksums, or reproducibility checks are - missing or inconsistent. - -## Non-goals - -- Do not create a custom Windows installer. -- Do not add download, extraction, upgrade, PATH, or uninstall behavior to `camel.exe`. -- Do not add the native executables to the standard cross-platform `-bin.zip` or `-bin.tar.gz`. -- Do not publish release payloads from snapshots. -- Do not rebuild the WinGet ZIP after the release vote. - -## Artifact Flow - -```text -released source and pinned llvm-mingw - | - v - tooling/camel-exe classified x64 and arm64 artifacts - | - | maven-dependency-plugin:copy - v - camel-launcher target staging directory - | - | maven-assembly-plugin:single, attach=false - v - camel-launcher--winget-bin.zip - | - +--> dist/dev release candidate + .asc + .sha512 - | | - | v after approval - | dist/release and Apache archive - | - +--> JReleaser checksum and WinGet manifest -``` - -## Maven Build Design - -The existing `include-camel-exe` profile remains responsible for declaring and copying the exact -native artifacts: - -- `org.apache.camel:camel-exe::exe:x64` -- `org.apache.camel:camel-exe::exe:arm64` - -The release build must resolve these artifacts from the same release reactor. It must not use -snapshot coordinates or reconstruct the ZIP from artifacts downloaded after the vote. - -The `assemble-winget-bin` execution remains bound to `package` and continues using -`src/main/assembly/winget-bin.xml`. Its execution configuration will set `attach` to `false`. -This leaves the generated ZIP in `camel-launcher/target` while excluding it from the Maven -project's attached artifacts. Consequently, Maven `install` and `deploy` will not copy it into a -local or remote Maven repository. Maven will not generate a `.sha1`, `.sha512`, or detached -signature for this non-attached ZIP. - -The assembly must be produced by Maven Assembly Plugin. Release scripts must not unpack and rezip -the distribution with platform-specific `zip`, PowerShell, or archive utilities because file order, -permissions, path separators, and timestamps may vary between implementations. - -## Reproducibility Contract - -The release build will rely on these controlled inputs: - -- The released source commit. -- The exact Maven and JDK versions required by the Camel release process. -- Maven Assembly Plugin 3.8.0 from the Camel parent plugin management. -- `project.build.outputTimestamp` from the released root POM. -- llvm-mingw version `20260616`, downloaded from the pinned filename and verified with the pinned - SHA-256 before use. -- Exact, versioned Maven coordinates for all copied artifacts. - -`project.build.outputTimestamp` supplies the timestamp for archive entries. The assembly descriptor -defines the file layout and file modes. `dependency:copy` copies the native executable bytes without -transforming them. - -A reproducibility gate will build the native executables and WinGet ZIP twice from clean output -directories using the release build instructions. It will compare SHA-256 values for: - -- `camel-x64.exe` -- `camel-arm64.exe` -- `camel-launcher--winget-bin.zip` - -Any mismatch fails the release. The first implementation must not claim that the native executable -build is reproducible until this gate passes. If it fails, the differing binaries must be inspected -before changing compiler or linker flags. - -The release process must stage the ZIP generated by the approved release candidate. Promotion must -copy that exact file from `dist/dev` to `dist/release`; it must not invoke Maven again. The detached -signature and SHA-512 file created by the Apache distribution staging step must accompany the ZIP. - -Camel's Maven release plugin already runs `deploy -Prelease` in its release checkout, and the -`release` profile enables `camel.exe.build`. After `release:perform`, the non-attached ZIP is read -from: - -```text -target/checkout/dsl/camel-jbang/camel-launcher/target/ - camel-launcher--winget-bin.zip -``` - -The release operator stages that file before starting the vote. The Nexus staging repository and -the WinGet payload candidate URL are both included in the same vote email. - -## Apache Distribution and URL Stability - -The release candidate will stage these files under the existing Camel release directory: - -- `camel-launcher--winget-bin.zip` -- `camel-launcher--winget-bin.zip.asc` -- `camel-launcher--winget-bin.zip.sha512` - -After the release vote, the files will be promoted with the other release artifacts. WinGet -publication must wait until the archived URL is available and its bytes match the promoted ZIP: - -```text -https://archive.apache.org/dist/camel/apache-camel//camel-launcher--winget-bin.zip -``` - -The WinGet manifest will use this versioned archive URL because manifests for older Camel versions -remain in `winget-pkgs` after a release leaves `downloads.apache.org`. The archive URL therefore -avoids invalidating older manifests when Apache rotates current releases. - -Before JReleaser publishes or submits the WinGet manifest, the packaging command must download or -hash the archived URL and compare it with the local ZIP. A missing URL or checksum mismatch is a -hard failure. The command may be rerun after Apache archive synchronization completes; it must not -regenerate the ZIP. - -## Release Script Integration - -A new `etc/scripts/stage-winget-distro.sh` command will prepare the WinGet release candidate. It -accepts the release version, candidate number, and path to the ZIP produced by `release:perform`. -It validates the filename and release version, creates the detached signature and SHA-512 file, -prepares an SVN working copy under: - -```text -https://dist.apache.org/repos/dist/dev/camel/apache-camel/-rc/ -``` - -Like the existing `release-distro.sh`, it stops before the remote commit and prints the exact SVN -status and commit commands for the release operator to review. - -The staging script, not Maven, creates -`camel-launcher--winget-bin.zip.asc` and -`camel-launcher--winget-bin.zip.sha512`. This matches the existing Apache distribution -flow in `release-distro.sh`, which removes Maven repository `.sha1` files and creates `.sha512` -files before preparing the `dist/release` working copy. - -After a successful vote, `etc/scripts/release-distro.sh` will accept an optional WinGet candidate -number. When supplied, it exports the approved ZIP, signature, and checksum from `dist/dev`, verifies -them, and adds those exact files to the local `dist/release` working copy alongside the artifacts it -already retrieves from the released Maven repository. The operator reviews and commits that combined -working copy using the existing release process, then removes the promoted candidate directory from -`dist/dev`. - -The release guide will place WinGet candidate staging after `release:perform` and before the vote. It -will place candidate promotion in the existing `release-distro.sh` step after the vote. - -## JReleaser Design - -The dedicated `camel-cli-winget` distribution remains because it describes a payload whose contents -differ from the public `camel-cli` ZIP. Its artifact path continues to reference the local -`target/camel-launcher--winget-bin.zip`. - -The WinGet `downloadUrl` changes from the Maven Central coordinate to the versioned Apache archive -URL. The existing custom installer template continues to emit two installer entries that share the -same ZIP and checksum while selecting different nested executables: - -- x64 selects `bin/camel-x64.exe`. -- arm64 selects `bin/camel-arm64.exe`. - -JReleaser must calculate `distributionChecksumSha256` from the same local ZIP whose SHA-512 and -signature were staged for the release. The packaging tests will assert the final URL and compare the -manifest SHA-256 with the local and remotely published bytes. - -## Failure Handling - -The release or package preparation fails when any of the following occurs: - -- Either native executable is missing. -- The WinGet ZIP is missing either native executable or required launcher content. -- A clean rebuild produces a different executable or ZIP checksum. -- Maven installs or deploys the WinGet ZIP as an attached artifact. -- A Maven-generated `.sha1` for the WinGet ZIP appears in the release staging input, indicating that - the ZIP was still attached and deployed. -- The detached signature or SHA-512 file is missing from the release candidate. -- The archived Apache URL is unavailable. -- The archived ZIP differs from the approved local ZIP. -- The generated WinGet manifest checksum differs from the approved ZIP. -- A snapshot version reaches release packaging. - -No failure may be converted into a skip. The operator may retry only availability checks after -Apache distribution synchronization; the approved artifact itself remains unchanged. - -## Verification - -The implementation will add or update focused verification for these behaviors: - -1. The native executable integration test verifies staging and both entries in the WinGet ZIP. -2. The standard ZIP and TAR tests verify that native executables remain excluded. -3. A Maven integration check deploys into a temporary file repository and verifies that no - `camel-launcher--winget-bin.zip` or corresponding `.sha1` is deployed. -4. A two-build reproducibility check compares the native executable and WinGet ZIP hashes. -5. Package-plan tests verify the Apache archive download URL and reject Maven Central as the WinGet - payload host. -6. Manifest tests verify both architectures, nested executable paths, and the exact ZIP checksum. -7. Release staging verification checks the ZIP, `.asc`, and `.sha512` files before promotion. -8. Remote verification compares the archived ZIP with the approved local ZIP before WinGet - publication. - -## Expected Code and Documentation Scope - -Implementation is expected to update only the files required by this release flow: - -- `dsl/camel-jbang/camel-launcher/pom.xml` -- `dsl/camel-jbang/camel-launcher/jreleaser.yml` -- `dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh` -- Focused launcher packaging and manifest tests -- Launcher and native executable packaging documentation -- `.github/workflows/camel-launcher-native-exe.yml` -- `etc/scripts/stage-winget-distro.sh` -- `etc/scripts/release-distro.sh` -- `docs/user-manual/modules/ROOT/pages/release-guide.adoc` - -No unrelated release or launcher refactoring is included. - -## Alternatives Rejected - -### Include native executables in the standard launcher ZIP - -This would remove the dedicated payload but would add two Windows-only binaries to every launcher -ZIP consumer. The approved design keeps the standard distribution architecture-neutral. - -### Custom installer executable - -A custom installer would still require a published installer artifact and would add network access, -checksum verification, extraction, silent installation, upgrade, rollback, PATH, and uninstall -behavior. WinGet's portable ZIP support already handles installation lifecycle without that added -security and maintenance surface. - -### Generate the ZIP after release publication - -Rebuilding or first generating the ZIP after the release vote would mean WinGet downloads bytes that -were not part of the approved release candidate. It would also make reproducibility and provenance -harder to verify. The approved ZIP is therefore created and staged before the vote. - -## Success Criteria - -- Maven Central contains no `camel-launcher--winget-bin.zip` artifact. -- The approved Apache release contains the ZIP, detached signature, and SHA-512 checksum. -- Two clean release builds produce identical x64 and arm64 executables and an identical WinGet ZIP. -- The promoted ZIP is byte-for-byte identical to the approved release candidate. -- The WinGet manifest references the immutable Apache archive URL and contains the correct SHA-256. -- WinGet selects `camel-x64.exe` on x64 and `camel-arm64.exe` on arm64 while retaining the complete - launcher distribution beside the selected executable. 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 32432cac0ccb7..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,50 +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 - - *.* - - - + + 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 index 8515d2fc85c36..fb4e1f6e2b5f5 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/assembly/winget-bin.xml +++ b/dsl/camel-jbang/camel-launcher/src/main/assembly/winget-bin.xml @@ -17,6 +17,14 @@ limitations under the License. --> + @@ -24,51 +32,10 @@ zip - - - bin - - org.jolokia:jolokia-agent-jvm:jar:javaagent - - false - - + + src/main/assembly/launcher-content.xml + - - ${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 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 index 61c69c812b9c5..1f187ca569020 100644 --- 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 @@ -24,18 +24,28 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.MessageDigest; -import java.time.LocalDate; import java.util.*; 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; /** @@ -111,20 +121,42 @@ static String sha256Hex(Path file) throws Exception { 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 productionSupportedLtsEntryIsDocumentedBeforeExpiry() throws Exception { - String supportedLts = Files.readString(MODULE_DIR.resolve("src/jreleaser/supported-lts.yml"), StandardCharsets.UTF_8); + 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); + } + } - assertTrue(supportedLts.contains("line: \"4.22\""), supportedLts); - assertTrue(supportedLts.contains("supportEnds: \"2027-09-30\""), - "The real 4.22 LTS entry is intentionally checked here, but plan tests use a synthetic future-dated" - + " fixture so they do not expire on 2027-10-01."); - assertTrue(LocalDate.parse("2027-09-30").isAfter(LocalDate.parse("2026-07-19")), - "Update this test when changing the documented 4.22 support window."); + @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 publicArchivePackagersDoNotRemoveNativeExecutables() throws Exception { + void archNeutralPackagersEnterThroughTheBatchLauncher() throws Exception { String chocolatey = Files.readString(MODULE_DIR.resolve( "src/jreleaser/distributions/camel-cli/chocolatey/tools/chocolateyinstall.ps1.tpl"), StandardCharsets.UTF_8); @@ -132,112 +164,175 @@ void publicArchivePackagersDoNotRemoveNativeExecutables() throws Exception { MODULE_DIR.resolve("src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl"), StandardCharsets.UTF_8); - assertFalse(chocolatey.contains("camel-x64.exe"), chocolatey); - assertFalse(chocolatey.contains("camel-arm64.exe"), chocolatey); - assertFalse(scoop.contains("camel-x64.exe"), scoop); - assertFalse(scoop.contains("camel-arm64.exe"), scoop); + 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 { - String publicAssembly = Files.readString(MODULE_DIR.resolve("src/main/assembly/bin.xml"), StandardCharsets.UTF_8); - String wingetAssembly - = Files.readString(MODULE_DIR.resolve("src/main/assembly/winget-bin.xml"), StandardCharsets.UTF_8); - String pom = Files.readString(MODULE_DIR.resolve("pom.xml"), StandardCharsets.UTF_8); - int nativeProfile = pom.indexOf("include-camel-exe"); + Path publicAssembly = MODULE_DIR.resolve("src/main/assembly/bin.xml"); + Path wingetAssembly = MODULE_DIR.resolve("src/main/assembly/winget-bin.xml"); - assertFalse(publicAssembly.contains("camel-x64.exe"), + List publicIncludes = effectiveIncludes(publicAssembly); + assertFalse(publicIncludes.contains("camel-x64.exe"), "the public ZIP/TAR assembly must not expose WinGet-only native executables"); - assertFalse(publicAssembly.contains("camel-arm64.exe"), + assertFalse(publicIncludes.contains("camel-arm64.exe"), "the public ZIP/TAR assembly must not expose WinGet-only native executables"); - assertTrue(wingetAssembly.contains("camel-x64.exe"), wingetAssembly); - assertTrue(wingetAssembly.contains("camel-arm64.exe"), wingetAssembly); - assertTrue(wingetAssembly.contains("*.jar"), - "the WinGet archive must retain the launcher JAR beside camel.bat"); - assertTrue(wingetAssembly.contains("camel.bat"), + + 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"); - assertTrue(wingetAssembly.contains("zip"), wingetAssembly); - assertFalse(wingetAssembly.contains("tar.gz"), wingetAssembly); - assertTrue(nativeProfile >= 0, "the native executable profile must exist"); - assertFalse(pom.substring(0, nativeProfile).contains("winget-bin.xml"), - "ordinary builds must not create an incomplete WinGet archive"); - String nativeProfilePom = pom.substring(nativeProfile); - int wingetExecutionStart = nativeProfilePom.indexOf("assemble-winget-bin"); - assertTrue(wingetExecutionStart >= 0, "the native executable profile must create the WinGet archive"); - int wingetExecutionEnd = nativeProfilePom.indexOf("", wingetExecutionStart); - assertTrue(wingetExecutionEnd >= 0, "the WinGet assembly execution must be complete"); - String wingetExecution = nativeProfilePom.substring(wingetExecutionStart, wingetExecutionEnd); - - assertTrue(wingetExecution.contains("src/main/assembly/winget-bin.xml"), - wingetExecution); - assertTrue(wingetExecution.contains("false"), - "the WinGet payload must remain a local release file, not an attached Maven artifact"); + 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 wingetUsesDedicatedJreleaserDistribution() throws Exception { - String config = Files.readString(MODULE_DIR.resolve("jreleaser.yml"), StandardCharsets.UTF_8); - int wingetStart = config.indexOf(" camel-cli-winget:"); + 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"); - assertTrue(wingetStart >= 0, config); - String wingetDistribution = config.substring(wingetStart); - assertTrue(wingetDistribution.contains("camel-launcher-{{projectVersion}}-winget-bin.zip"), config); - assertTrue(wingetDistribution.contains( - "https://archive.apache.org/dist/camel/apache-camel/{{projectVersion}}/{{artifactFile}}"), config); - assertFalse(wingetDistribution.contains("repo1.maven.org"), wingetDistribution); - assertFalse(config.substring(config.indexOf("camel-cli:"), wingetStart) - .contains("winget:"), "the public distribution must not generate the WinGet package"); + 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 - void nativeWorkflowChecksReproducibilityAndMavenExclusion() throws Exception { - String workflow = Files.readString( - MODULE_DIR.resolve("../../../.github/workflows/camel-launcher-native-exe.yml").normalize(), - StandardCharsets.UTF_8); + @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()); - assertTrue(workflow.contains("-name 'camel-launcher-*-winget-bin.zip'"), workflow); - assertTrue(workflow.contains("sha256sum --check \"$HASH_FILE\""), workflow); - assertTrue(workflow.contains("-DaltSnapshotDeploymentRepository=\"winget-check::file://$MAVEN_REPO\""), - workflow); - assertTrue(workflow.contains("-name '*-winget-bin.zip'"), workflow); - assertTrue(workflow.contains("-name '*-winget-bin.zip.sha1'"), workflow); + 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); - assertTrue(installer.contains("bin\\camel.bat"), installer); assertFalse(installer.contains("camel-x64.exe"), installer); assertFalse(installer.contains("camel-arm64.exe"), installer); - assertFalse(installer.contains("PROCESSOR_ARCHITECTURE"), 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 - void scoopAutoupdateUsesPublishedMavenCentralSha1Sidecar() throws Exception { - String scoop = Files.readString( - MODULE_DIR.resolve("src/jreleaser/distributions/camel-cli/scoop/manifest.json.tpl"), - StandardCharsets.UTF_8); + @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(scoop.contains("\"url\": \"$url.sha1\""), - "Scoop autoupdate should use the SHA-1 sidecar that Maven Central publishes for camel-launcher" - + " classified artifacts."); + 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 wingetInstallerManifestUsesLatestAcceptedSchema() throws Exception { + 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("winget-manifest.installer.1.12.0.schema.json"), winget); - assertTrue(winget.contains("ManifestVersion: 1.12.0"), winget); 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, winget.split("InstallerSha256: \\{\\{distributionChecksumSha256\\}\\}", -1).length - 1, + 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)); + } + + 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"); @@ -264,14 +359,6 @@ private Result run(Map extraEnv, String... args) throws Exceptio return r; } - @Test - void posixWrapperUsesBashWithPipefail() throws Exception { - String script = Files.readString(SCRIPT, StandardCharsets.UTF_8); - - assertTrue(script.startsWith("#!/usr/bin/env bash\n"), script); - assertTrue(script.contains("set -euo pipefail"), script); - } - private Map testModeEnvWithMvnStub(Path tmp, Path recordFile) throws IOException { Path stubDir = tmp.resolve("stub-bin"); Files.createDirectories(stubDir); @@ -355,17 +442,6 @@ private Map productionStyleEnvWithMvnStub(Path tmp, Path recordF return env; } - private void addFailingCurlStub(Path tmp, Map env, Path curlRecordFile) throws IOException { - Path stubDir = tmp.resolve("curl-stub-bin"); - Files.createDirectories(stubDir); - Path curlStub = stubDir.resolve("curl"); - Files.writeString(curlStub, - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"" + curlRecordFile + "\"\nexit 99\n", - StandardCharsets.UTF_8); - assertTrue(curlStub.toFile().setExecutable(true)); - env.put("PATH", stubDir + File.pathSeparator + env.getOrDefault("PATH", System.getenv("PATH"))); - } - @Test void stableSelectsAllFivePackagersWithSdkmanDefault() throws Exception { Result r = run("prepare", "--channel", "stable", "--print-plan"); @@ -590,7 +666,11 @@ void ltsStagesInstallersButDoesNotWriteLatest(@TempDir Path tmp) throws Exceptio writeReleaseFixture("-bin.tar.gz", "fixture-tar-lts"); writeReleaseFixture("-bin.zip", "fixture-zip-lts"); Path recordFile = tmp.resolve("mvn-calls.txt"); - Map env = testModeEnvWithMvnStub(tmp, recordFile); + // 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); @@ -622,23 +702,28 @@ void ltsPrepareRenamesGeneratedHomebrewFormula(@TempDir Path tmp) throws Excepti "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 testModeWithoutSyntheticVersionDoesNotPatchGeneratedHomebrewFormula(@TempDir Path tmp) throws Exception { + 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"); - Path curlRecordFile = tmp.resolve("curl-calls.txt"); Map env = envWithMvnStubProducingFormula(tmp, recordFile, "camel.rb"); env.put("CAMEL_PACKAGE_TEST_MODE", "true"); - addFailingCurlStub(tmp, env, curlRecordFile); + env.put("CAMEL_PACKAGE_TEST_VERSION", TEST_VERSION); Result r = run(env, "prepare", "--channel", "stable"); assertEquals(0, r.exit, r.stderr); - assertFalse(Files.exists(curlRecordFile), - "Formula patching must require CAMEL_PACKAGE_TEST_VERSION, not CAMEL_PACKAGE_TEST_MODE alone"); - Path formulaFile = PACKAGE_DIR.resolve("camel-cli/brew/Formula/camel.rb"); - assertTrue(Files.readString(formulaFile, StandardCharsets.UTF_8).contains("version \"" + TEST_VERSION + "\"")); + 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 @@ -679,4 +764,73 @@ void testVersionOverrideRequiresExplicitTestMode(@TempDir Path tmp) throws Excep 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; + } } From 0b14a76143eb16a72b78fcd15ec904146b9ca8a9 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:57:27 -0400 Subject: [PATCH 17/23] CAMEL-23703: Align WinGet manifest schema versions Co-authored-by: Codex --- dsl/camel-jbang/camel-launcher/pom.xml | 2 + .../camel-cli/winget/locale.yaml.tpl | 44 +++++++++++++++++++ .../camel-cli/winget/version.yaml.tpl | 25 +++++++++++ .../dsl/jbang/launcher/PackagePlanTest.java | 21 +++++++++ 4 files changed, 92 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/locale.yaml.tpl create mode 100644 dsl/camel-jbang/camel-launcher/src/jreleaser/distributions/camel-cli/winget/version.yaml.tpl diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 94ac81f374448..476d76a057754 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -363,6 +363,8 @@ 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 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/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 index 1f187ca569020..0379a7886e48e 100644 --- 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 @@ -331,6 +331,27 @@ void wingetInstallerManifestDeclaresBothArchitecturesFromOneArchive() throws Exc "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; } From 698ee62e77dddf8d6042e368a9be7eda575d972f Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:08:59 -0400 Subject: [PATCH 18/23] CAMEL-23703: Pin the PE timestamp so camel.exe builds reproducibly The PE format carries a mandatory TimeDateStamp, and lld fills it with the current wall-clock time unless told otherwise. Two identical builds therefore produced byte-different executables, and the difference propagated into the WinGet archive whose SHA-256 is published in the manifest and voted on during the release. Pass -Wl,--no-insert-timestamp to both cross-compiles. Also stash the first-build executables before the reproducibility check runs 'clean verify', so a future mismatch reports the differing byte offsets rather than a bare FAILED. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/camel-launcher-native-exe.yml | 16 +++++++++++++++- tooling/camel-exe/pom.xml | 10 ++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/camel-launcher-native-exe.yml b/.github/workflows/camel-launcher-native-exe.yml index bfdfba5d683cc..5ab86fd53f1d4 100644 --- a/.github/workflows/camel-launcher-native-exe.yml +++ b/.github/workflows/camel-launcher-native-exe.yml @@ -209,11 +209,25 @@ jobs: dsl/camel-jbang/camel-launcher/target/camel-launcher-*-winget-bin.zip \ > "$HASH_FILE" + # 'clean verify' wipes target, so keep the first-build executables around; + # on a mismatch the differing byte offsets 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 "$FIRST_BUILD" + mvn -P apache-snapshots \ -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ -Dcamel.exe.build=true -DskipTests - sha256sum --check "$HASH_FILE" + 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 + exit 1 + fi - name: Verify WinGet archive is not deployed by Maven run: | MAVEN_REPO="$RUNNER_TEMP/winget-maven-repo" diff --git a/tooling/camel-exe/pom.xml b/tooling/camel-exe/pom.xml index 490e03e0be820..6a3a2bec4600e 100644 --- a/tooling/camel-exe/pom.xml +++ b/tooling/camel-exe/pom.xml @@ -144,6 +144,14 @@ -static -mconsole -municode + + -Wl,--no-insert-timestamp -o ${project.build.directory}/camel-x64.exe ${project.basedir}/src/main/native/camel.c @@ -165,6 +173,8 @@ -static -mconsole -municode + + -Wl,--no-insert-timestamp -o ${project.build.directory}/camel-arm64.exe ${project.basedir}/src/main/native/camel.c From 5fe281ce30b32c240233127c6f47990b40fe4bcb Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:21:40 -0400 Subject: [PATCH 19/23] CAMEL-23703: Make the repackaged launcher JAR reproducible RepackageMojo called the two-argument Repackager.repackage, which leaves the last-modified time null. That null drives two separate mechanisms in Spring Boot's packager: every entry is stamped with the current wall-clock time, and BOOT-INF/lib falls back to a LinkedHashMap ordered by the dependency set's iteration order rather than a sorted TreeMap. Consecutive builds of the same source therefore produced different JARs, and the JAR is the payload of the WinGet archive whose SHA-256 is published in the manifest and voted on during the release. Pass the pinned project.build.outputTimestamp instead, read through maven-archiver's parseBuildOutputTimestamp so the ISO-8601 and epoch-second forms and the SOURCE_DATE_EPOCH fallback all behave as Maven documents them. The three-argument overload is @since 4.0.0, so this only became available with the recent Spring Boot upgrade. Also extend the workflow's reproducibility diagnostic to diff the WinGet archive's entry listing, which distinguishes changed content from a changed timestamp. Co-Authored-By: Claude Opus 4.8 --- .../workflows/camel-launcher-native-exe.yml | 12 ++++-- .../camel-repackager-maven-plugin/pom.xml | 10 +++++ .../org/apache/camel/maven/RepackageMojo.java | 25 ++++++++++- .../apache/camel/maven/RepackageMojoTest.java | 43 +++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/.github/workflows/camel-launcher-native-exe.yml b/.github/workflows/camel-launcher-native-exe.yml index 5ab86fd53f1d4..066ac21f54f84 100644 --- a/.github/workflows/camel-launcher-native-exe.yml +++ b/.github/workflows/camel-launcher-native-exe.yml @@ -209,12 +209,13 @@ jobs: dsl/camel-jbang/camel-launcher/target/camel-launcher-*-winget-bin.zip \ > "$HASH_FILE" - # 'clean verify' wipes target, so keep the first-build executables around; - # on a mismatch the differing byte offsets say far more than a bare FAILED. + # '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 "$FIRST_BUILD" + tooling/camel-exe/target/camel-arm64.exe \ + dsl/camel-jbang/camel-launcher/target/camel-launcher-*-winget-bin.zip "$FIRST_BUILD" mvn -P apache-snapshots \ -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ @@ -226,6 +227,11 @@ jobs: 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 exit 1 fi - name: Verify WinGet archive is not deployed by Maven 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..765816809dd87 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; @@ -79,6 +81,12 @@ public class RepackageMojo extends AbstractMojo { @Parameter(defaultValue = "true") private boolean backupSource; + /** + * Timestamp for reproducible output, either formatted as ISO-8601 or as seconds since the epoch. + */ + @Parameter(defaultValue = "${project.build.outputTimestamp}") + private String outputTimestamp; + @Override public void execute() throws MojoExecutionException, MojoFailureException { try { @@ -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..6798ebe7b1ffc 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,6 +17,8 @@ package org.apache.camel.maven; import java.io.File; +import java.nio.file.attribute.FileTime; +import java.time.Instant; import org.apache.maven.artifact.Artifact; import org.apache.maven.artifact.DefaultArtifact; @@ -104,6 +106,47 @@ 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)); } From b07942cc24f7fc3c3efb0d0b6c5b781b654dd803 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:35:53 -0400 Subject: [PATCH 20/23] CAMEL-23703: Replace the placeholder repackager tests with real ones RepackageMojoTest carried three tests that asserted assertTrue(true) with a comment describing what they would check one day. They could not fail, so they reported coverage over exactly the JAR structure this branch is changing. Drive the mojo over a staged source JAR instead and assert the properties that matter: application classes under BOOT-INF/classes, dependencies under BOOT-INF/lib, loader classes at the root, Main-Class pointing at the loader with Start-Class pointing at the application, and the scope filter holding at the packaged output rather than only at includeArtifact. Mojo fields become protected so the test can set them, matching how EndpointSchemaGeneratorMojoTest drives its mojo. Two findings worth recording. Spring Boot adds spring-boot-jarmode-tools to BOOT-INF/lib on its own, so the launcher ships it today; the test asserts around it rather than silently encoding it as intended. And testRepackagingIsReproducible still passes when the outputTimestamp fix is reverted: with a null time Spring Boot derives each nested library's entry time from that library's first entry, which the fixture pins, so the fixture cannot reproduce the real conditions. The reproducibility fix therefore remains unproven by unit test. Also make the workflow install camel-repackager-maven-plugin before the launcher build. It is outside the -pl list, so the job was resolving it from the Apache snapshot repository and never exercising the change, and extend the failure diagnostic to diff the launcher JAR's own entry listing. Co-Authored-By: Claude Opus 4.8 --- .../workflows/camel-launcher-native-exe.yml | 17 ++ .../org/apache/camel/maven/RepackageMojo.java | 12 +- .../apache/camel/maven/RepackageMojoTest.java | 157 ++++++++++++++++-- 3 files changed, 164 insertions(+), 22 deletions(-) diff --git a/.github/workflows/camel-launcher-native-exe.yml b/.github/workflows/camel-launcher-native-exe.yml index 066ac21f54f84..f97c88284f552 100644 --- a/.github/workflows/camel-launcher-native-exe.yml +++ b/.github/workflows/camel-launcher-native-exe.yml @@ -174,6 +174,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 install. + run: | + mvn -P apache-snapshots -B -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 @@ -232,6 +240,15 @@ jobs: 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 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 765816809dd87..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 @@ -49,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. @@ -61,31 +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}") - private String outputTimestamp; + protected String outputTimestamp; @Override public void execute() throws MojoExecutionException, MojoFailureException { 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 6798ebe7b1ffc..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,12 +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; @@ -33,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(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); - assertTrue(true, "Placeholder test - would verify dependency inclusion"); + assertArrayEquals(Files.readAllBytes(first.toPath()), Files.readAllBytes(second.toPath()), + "repackaging the same input twice must produce identical bytes"); } @Test @@ -150,4 +208,71 @@ public void testInvalidOutputTimestampFailsLoudly() { 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()); + } + } } From cea612fa46eaab543291d26363a68eb33f53ed35 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:25:05 -0400 Subject: [PATCH 21/23] CAMEL-23703: Share one content definition between both launcher archives bin.xml and winget-bin.xml were near-duplicates differing only by the tar.gz format and one fileSet. The two archives must deliver the same CLI, so a file added to one could silently go missing from the other. Move the shared dependencySet and fileSets into launcher-content.xml and reference it from both descriptors, leaving each with only its id, formats and, for WinGet, the native executables. Verified behaviour-preserving: the public archive's entry list is byte-identical before and after, and the WinGet archive differs from it by exactly bin/camel-x64.exe and bin/camel-arm64.exe. PackagePlanTest: replace assertions that matched substrings in pom.xml, jreleaser.yml, the assembly descriptors and a GitHub workflow with structural XML, YAML and JSON parsing, so reformatting cannot break a test while a real content change slips through. Includes resolve through the shared component, so they describe what each archive carries rather than which file declares it. Drop the assertion that compared two hardcoded dates, which could never fail, in favour of running the wrapper against every line the production allowlist advertises: that starts failing when an LTS line's support window lapses, which is the intended signal. Drop the test asserting the workflow file's contents and the one asserting the wrapper's shebang; CI running is the verification for the former, and fifteen execution tests cover the latter. Remove the agent planning and design documents committed under docs/, which is the Antora source tree and not a home for working notes. Co-Authored-By: Claude Opus 4.8 --- .../apache/camel/dsl/jbang/launcher/PackagePlanTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 0379a7886e48e..f0a55b681a708 100644 --- 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 @@ -24,7 +24,13 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.MessageDigest; -import java.util.*; +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; From 28fede20dfa5bf56c471ccd08eeb862634f849f0 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:06:12 -0400 Subject: [PATCH 22/23] CAMEL-23703: Run camel-launcher-native mvn steps in batch mode Batch mode (-B) avoids interactive-mode ANSI/progress noise in CI logs for the exe-build and installation steps that were missing it. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/camel-launcher-native-exe.yml | 15 ++++++++------- dsl/camel-jbang/camel-launcher/pom.xml | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/camel-launcher-native-exe.yml b/.github/workflows/camel-launcher-native-exe.yml index f97c88284f552..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 @@ -178,9 +179,9 @@ jobs: # 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 install. + # plugin produced by the same reactor invocation, hence the separate installation. run: | - mvn -P apache-snapshots -B -ntp \ + 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 @@ -194,7 +195,7 @@ 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 @@ -225,7 +226,7 @@ jobs: tooling/camel-exe/target/camel-arm64.exe \ dsl/camel-jbang/camel-launcher/target/camel-launcher-*-winget-bin.zip "$FIRST_BUILD" - mvn -P apache-snapshots \ + mvn -B -P apache-snapshots \ -pl buildingtools,tooling/camel-exe,dsl/camel-jbang/camel-launcher clean verify \ -Dcamel.exe.build=true -DskipTests @@ -254,7 +255,7 @@ jobs: - name: Verify WinGet archive is not deployed by Maven run: | MAVEN_REPO="$RUNNER_TEMP/winget-maven-repo" - mvn -P apache-snapshots \ + 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" diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 476d76a057754..c023ff1ded30c 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -65,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. --> From 1f0f9e17f602462b11df35124d26242c7920c3f9 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:07:33 -0400 Subject: [PATCH 23/23] CAMEL-23703: Clarify why publish is unimplemented pending a PMC decision The "Phase 5" references were tracked only in local notes, not on any public tracker, and read like a scheduled implementation phase rather than what they actually are: publish is blocked on the Apache Camel PMC deciding where each packager's artifacts should go (e.g. Homebrew to this project's own tap vs. homebrew-core). The Homebrew dual-formula gap is blocked on that same decision, not a separate follow-up. Co-Authored-By: Claude Sonnet 5 --- dsl/camel-jbang/camel-launcher/jreleaser.yml | 13 ++++++++----- .../src/jreleaser/bin/camel-package.sh | 8 ++++++-- .../distributions/camel-cli/sdkman/release-notes.md | 6 +++--- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/dsl/camel-jbang/camel-launcher/jreleaser.yml b/dsl/camel-jbang/camel-launcher/jreleaser.yml index 89788505f4d67..a3b08d07a841e 100644 --- a/dsl/camel-jbang/camel-launcher/jreleaser.yml +++ b/dsl/camel-jbang/camel-launcher/jreleaser.yml @@ -28,7 +28,7 @@ # - 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 at Phase 5 publish time. +# 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 @@ -52,9 +52,12 @@ # 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 is deferred to a follow-up task; use -# a separate `--channel lts --lts-line X.Y` run to produce the versioned formula -# on its own in the meantime. +# 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 @@ -129,7 +132,7 @@ distributions: 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 is deferred to Phase 5 publish. + # 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}}" diff --git a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh index ce773b18f3ccc..a49369c59efde 100755 --- a/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh +++ b/dsl/camel-jbang/camel-launcher/src/jreleaser/bin/camel-package.sh @@ -144,8 +144,12 @@ if [ "$PRINT_PLAN" -eq 1 ]; then fi if [ "$SUBCOMMAND" = "publish" ]; then - # Publication is implemented in Phase 5. - echo "Error: 'publish' is not yet implemented (Phase 5)." 1>&2 + # 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 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 index 4927c4f848fa0..fea5d6177f7fb 100644 --- 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 @@ -18,9 +18,9 @@ Apache Camel CLI requires Java 17 or newer. If you do not already have a