简介:一份面向Java开发者与DevOps人员的实用技术手册,围绕JReleaser这一开源自动化工具,系统讲解如何打造跨平台打包发布流水线。文档从跨平台打包的常见痛点切入,依次涵盖JReleaser核心概念、JAR/ZIP/DMG/MSI等打包格式、Maven/Gradle项目集成、GitHub/GitLab等发布目标配置,并延伸到Jenkins、GitLab CI/CD等持续集成场景,帮助读者理解从构建、打包到自动发布的全过程。资源为单个PDF文件,约4.18MB,支持目录章节跳转与左侧大纲定位,文字、图表、代码示例显示完整。目前已有59人学习下载,适合正在学习Java构建发布链路、准备引入自动化发布工具的中高级开发者查阅。文档内容条理清晰,包含常见问题排查与最佳实践,可作为实际项目中的配置参考。
1. JReleaser只做一件事:把已经打好的安装包变成一次完整发布
做过 Java 桌面端或 CLI 工具发布的人大概率经历过这种状态:jpackage 打好 dmg、msi、deb,然后手动上传 GitHub Releases、写 changelog、再跑到 Homebrew 仓库提一个 PR、最后去 Docker Hub 补一份镜像——一个版本折腾一小时,下次发布还得照着操作文档重来一遍。JReleaser 解决的正是这件事:它不负责编译,也不干预你用什么方式打安装包,它把「跨平台产物上传、版本标记、更新渠道提交、分发通知」这些发布动作编排成一条可重复执行的流水线。你写好一份 YAML 配置,在 CI 里跑一次,Windows、macOS、Linux 的产物和对应的发布渠道同步完成。适合手里有 JVM 系项目、发布节奏固定、不想每次手动操作各平台发布页面的团队。本文从 jpackage 跨平台打包开始,讲到 JReleaser 的项目配置,再落到一条 GitHub Actions 流水线上,最后把最容易翻车的点逐一拆开。
2. 先解决跨平台打包:为什么流水线必须从 jpackage 说起
JReleaser 只认「已经存在的文件」。它不会把你的一堆 class 文件变成 dmg 或 msi,也不会替你决定用 jpackage、GraalVM Native Image 还是简单地打 tar.gz。所以一条完整的发布流水线,第一步永远是:在对应的操作系统上,把应用打成对应平台的安装包。这里最常用也最省事的方案是 JDK 自带的 jpackage,从 JDK 14 引入,到 JDK 17 已经相当稳定,不需要额外装第三方打包工具。
2.1 jpackage 三平台打包的最小命令与产物差异
jpackage 的核心逻辑是一台机器只打一个目标平台的包,不要在 macOS 上交叉打 Windows 的 msi,也不要试图在 Ubuntu 上生成 dmg。它底层依赖系统工具,比如 Linux 的 deb 需要 dpkg-deb、rpm 需要 rpmbuild,Windows 的 msi 需要 WiX Toolset,macOS 的 dmg 依赖系统自带的 hdiutil。这些工具在打包机上缺一不可,CI 里需要预先安装。
先看 Linux 下打 deb 的最小命令:
jpackage \ --input target/lib \ --main-jar myapp.jar \ --main-class com.example.Main \ --type deb \ --name myapp \ --app-version 1.0.0 \ --dest target/dist \ --java-options '-Xms512m -Xmx2g'这段命令从target/lib目录读取项目依赖 jar 和应用 jar,以com.example.Main作为启动入口,生成一个 deb 安装包到target/dist。--java-options里的参数会写进启动配置,这是桌面应用最常见的调优出口。注意:JVM 参数一定要在这里通过--java-options指定,而不是依赖用户在启动脚本里手动改,否则不同平台的行为会不一致。
Windows 上打 msi 时,除了--type msi,还要额外传入--win-menu和--win-shortcut,否则装完在开始菜单和桌面都找不到程序入口。macOS 打 dmg 则建议加--mac-package-identifier和--mac-package-name,这两个值分别对应 Bundle Identifier 和安装后菜单里显示的应用名,不规范会导致 macOS 的 Gatekeeper 对签名和公证提出额外要求。三平台共用的参数是--input、--main-jar、--dest,差异集中在平台专属参数上。把这个命令分别放到三个操作系统的 CI worker 上执行,得到的就是三份原生安装包。
2.2 如果你的应用不只是纯 JVM 程序
不少 Java 项目其实长这样:Spring Boot 后端内嵌了一个前端页面,前端资源被打进 jar 包里,再用 jpackage 包成桌面应用。这类混合产物打包时要额外检查一点:--input目录里必须包含完整的资源目录结构,而不是只有 class 和依赖。实际中经常遇到本地运行正常、打包后页面 404 的情况,原因就是前端静态资源没被复制进--input。
我的做法是先把前端构建产物拷贝到 Spring Boot 的src/main/resources/static下,再执行mvn package,让前端资源进入 jar,最后 jpackage 只认这一个 jar。这样整个流水线里只有一条命令需要维护,CI 里也不用专门处理前端资产。如果你的应用要附带 JRE 一起分发,jpackage 默认会把所在 JDK 精简后塞进安装包,不需要额外配置,但要记得流水线里的 JDK 版本就是用户的运行时版本——在 CI 里装了 JDK 21 打出来的包,用户机器上跑的就是 JDK 21。
2.3 产物组织方式决定 JReleaser 配置的复杂程度
跨平台打包完成后,你会得到一批文件名带平台后缀的安装包,例如myapp-1.0.0.deb、myapp-1.0.0.rpm、myapp-1.0.0.msi、myapp-1.0.0.dmg,外加一份target/dist里的通用二进制压缩包。为了让 JReleaser 能统一找到这些文件,我建议在流水线里设置一个固定的产物目录,比如build/release/,把三平台的安装包都汇总到这里,再交给后续步骤处理。JReleaser 不关心这些文件是哪个平台打出来的,它只负责把它们上传到 GitHub Releases,并根据配置额外生成 Homebrew、Scoop、Docker 等分发渠道的引用。
这里要特别提醒:不要试图在流水线里把 tar.gz 和 dmg 混在一个目录后,再用文件后缀去猜平台类型。JReleaser 按文件匹配规则上传,宁可把文件名做得规则化,也不要让配置里写一堆模糊匹配。后面章节的 YAML 配置会依赖这里的命名习惯。
3. JReleaser 的核心配置:一份 YAML 管住所有发布渠道
JReleaser 的配置模型可以浓缩成一句线:项目信息、产物归属、发布渠道、通知动作。它不像 IDE 里一键打包那种黑匣子,所有字段都是可见的,你可以精确控制「什么文件进哪个渠道」。第一次配置时不要贪多,先把 GitHub Releases 跑通,再逐步加 Homebrew 和 Docker。
3.1 从.jreleaser.yml的最小可用配置入手
下面这份配置对应着一个最简单的场景:一个 GitHub 仓库,一个叫myapp的 distribution,产物目录里有一批安装包,目标是全部传到 GitHub Releases 上,并打上 tag:
project: name: myapp version: 1.0.0 description: My demo application longDescription: A longer project description website: https://example.com license: Apache-2.0 java: groupId: com.example artifactId: myapp authors: - name: your-name email: you@example.com release: github: owner: your-github-name name: myapp tagName: "v{{projectVersion}}" overwrite: false skipTag: false changelog: formatted: ALWAYS preset: conventional-commits contributors: format: "- {{contributorName}} ({{contributorUsernameAsLink}})" distributions: myapp: artifacts: - path: target/dist/{{projectName}}-{{projectVersion}}.deb - path: target/dist/{{projectName}}-{{projectVersion}}.rpm - path: target/dist/{{projectName}}-{{projectVersion}}.msi - path: target/dist/{{projectName}}-{{projectVersion}}.dmg - path: target/dist/{{projectName}}-{{projectVersion}}.tar.gz packagers: - type: brewtagName里的{{projectVersion}}是模板占位符,发布时会被替换成配置里的1.0.0,最终生成v1.0.0这个 tag。overwrite: false是有意为之的——同一个 tag 重复发布时,JReleaser 会直接报错而不是静默覆盖,这能挡住很多因为 tag 复用导致的版本混乱。artifacts字段是你最需要花时间对齐的地方,它写的路径必须和上一章 jpackage 的--dest输出目录一致,路径里同样可以使用模板变量。
3.2 发布渠道不止 GitHub Releases
很多人把 JReleaser 理解成「GitHub Releases 上传工具」,这低估了它。它真正的价值在packagers和announce两层:产物传到 GitHub 只是第一跳,第二跳是把安装信息同步到 Homebrew、Scoop、Docker Hub,再通过邮件或社交账号通知到人。以最常见的 Homebrew 为例,它会自动帮你修改 homebrew-core 或自己的 tap 仓库,提交一个新的 Formula 文件,把下载 URL 指向刚上传的 tar.gz 并计算 SHA-256 校验值。
举一个常见的配置片段:
distributions: myapp: brew: active: ALWAYS repository: owner: your-github-name name: homebrew-tap formulaName: myapp tap: owner: your-github-name name: homebrew-tap这段配置的含义是:每次发布时,JReleaser 会在homebrew-tap仓库里生成或更新myapp.rb公式文件。repository指向的是放公式的仓库,tap指向最终用户执行brew install时访问的仓库,大多数自建 tap 场景下这两个值是一样的。需要注意:JReleaser 不会帮你创建仓库,如果homebrew-tap不存在,发布会在这一步失败。所以流水线第一次跑之前,先去 GitHub 确认所有目标渠道仓库都已经存在。
Docker 渠道类似,JReleaser 会构建发布镜像并推送到 Docker Registry。但和 Homebrew 不同,Docker 渠道要求你的机器上有 Docker 环境,且需要预先做 docker login。在 GitHub Actions 里这步要用docker/login-action完成,并且推荐把 Jenkins 或 IDEA 里手动打镜像的习惯改掉——手动打出来的镜像标签零散,流水线里统一用 JReleaser 配置的{{projectVersion}}做版本标签,和 GitHub Releases 版本对齐。
3.3 发布 webapi 项目时的常见变体:二进制与容器二选一
如果你的项目是 Web API 后端,没有桌面应用形态,jpackage 部分可以整体省掉,产物组织方式要相应调整。常见的做法是:Maven 构建生成 fat jar 或 Spring Boot executable jar,把这个 jar 作为唯一的 artifact 传给 JReleaser,同时 Docker 渠道负责镜像推送。这种情况下artifacts里不需要写.deb、.dmg,只需写target/myapp.jar或直接交给 Docker 渠道处理。JReleaser 不会因为「没有原生安装包」就拒绝运行——它只处理配置里显式列出的内容。
之前有几个发布 Web API 项目的团队问我:用了 JReleaser 之后还需要在服务器上手动跑docker compose up吗?答案是:发布和部署是两件事。JReleaser 把镜像推到镜像仓库,服务器上拉取镜像并重启容器是 CD 环节的事,可以由其他工具负责。配置里保持单一职责,不要试图让 JReleaser 顺手帮你重启服务器。这条边界划清楚,后面排查问题时思路会顺畅很多。
4. 把 JReleaser 接进 GitHub Actions:一条完整的跨平台发布流水线
本地手工跑 JReleaser 只能验证配置,真正的发布流水线要落到 CI 上。GitHub Actions 提供了 macOS、Windows、Ubuntu 三个平台的 runner,正好对应 jpackage 三平台打包的需求。整体设计思路是:用一个 workflow,触发条件是推送版本 tag,三个平台并行执行打包,产物汇总后由最后一个平台执行 JReleaser 发布。下面的配置可以直接复制到.github/workflows/release.yml使用。
4.1 三平台打包与汇总的 Workflow 骨架
name: release on: push: tags: - "v*" permissions: contents: write packages: write jobs: build: strategy: fail-fast: false matrix: include: - os: ubuntu-latest type: deb - os: ubuntu-latest type: rpm - os: windows-latest type: msi - os: macos-latest type: dmg runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - name: Build with Maven run: mvn -B clean package shell: bash - name: Run jpackage run: | mkdir -p build/dist jpackage \ --input target/lib \ --main-jar myapp.jar \ --main-class com.example.Main \ --type ${{ matrix.type }} \ --name myapp \ --app-version ${{ env.VERSION }} \ --dest build/dist - name: Upload artifact uses: actions/upload-artifact@v4 with: name: dist-${{ matrix.os }}-${{ matrix.type }} path: build/dist/* release: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - name: Download all artifacts uses: actions/download-artifact@v4 with: path: build/dist - name: Run JReleaser env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: jbang jreleaser@latest release --auto-configfail-fast: false很关键。默认情况下,矩阵里任何一个平台的打包失败会取消其余任务。但发布场景里,Linux 打 deb 失败不应该影响 macOS 打 dmg——你仍然希望拿到其余的安装包产物。--auto-config让 JReleaser 自动读取仓库根目录的.jreleaser.yml,不需要在命令里手动指定路径。GITHUB_TOKEN使用 Actions 自动注入的 token,权限范围在 workflow 顶部的permissions中设置。
4.2 版本号从哪里来:tag 与配置的对齐策略
流水线里最常出问题的地方是版本号来源。上面的 workflow 用${{ env.VERSION }}占位,实际使用时,我建议把它显式设置成一次发布的标准输入。常见做法是让用户创建 tag 时输入完整的版本号,workflow 从github.ref_name中提取并去掉前缀v,再同时传给 jpackage 和 JReleaser:
- name: Extract version run: | TAG_NAME="${GITHUB_REF_NAME#v}" echo "VERSION=$TAG_NAME" >> $GITHUB_ENV这段代码是 Bash 版本,Windows runner 上因为有 Git Bash,也可以正常运行。提取之后,jpackage 的--app-version、.jreleaser.yml里project.version、最终生成的 tagv{{projectVersion}}三者保持一致,整个发布链路才自洽。很多人在这个环节翻车:tag 打的是v1.0.1,.jreleaser.yml里写的还是1.0.0,JReleaser 会把 tag 重新创建成v1.0.0并上传1.0.0的文件,用户拿到的是两个版本号不一致的产物。规避方法就是让配置里的版本跟随 tag 动态注入,而不是写死。
4.3 JReleaser 的 JIT 运行方式与安装选择
上面 workflow 里我用的是jbang jreleaser@latest,这是官方推荐的不落地安装方式:jbang 会自动下载对应版本的 JReleaser 并执行,不需要在 CI 里维护 JReleaser 的安装版本。但「latest」这种写法有风险——JReleaser 发布新版本时,如果你的配置使用了旧字段,新版本可能直接报错。发布流水线应该保持可重复性,因此我建议锁定一个已知稳定版本,例如:
jbang jreleaser@1.13.0 release --auto-config锁定版本后,流水线不会因为上游发版而意外行为变化。如果团队里已经有 Maven 工程,也可以引入org.jreleaser:jreleaser-maven-plugin作为 Maven 插件,在mvn -B clean deploy之后直接用插件触发,少一个依赖 jbang 的环节。两种方式选一种即可,不要混用,因为配置读取逻辑有细微差异。
4.4 IDE 打包习惯与流水线的取舍
很多 Java 开发者习惯在 IDEA 里直接选择 Run Configuration 里的 Maven 或 Gradle 任务完成打包,这在一个人的项目里没问题,但要发布到团队级别时就不够了。IDEA 的打包只覆盖当前机器的当前平台,无法同时产出三平台的安装包,更不会自动帮你做 Homebrew 更新。换句话说,IDEA 适合开发期验证 jpackage 参数是否正确,发布动作必须交给这条流水线。我见过有团队把 IDEA 手动打包的安装包传到 GitHub Releases,然后抱怨下载页没有 mac 版本——原因就是本地是 Windows,跳过了 mac 的构建矩阵。流水线里矩阵的自动并行,才是真正解决「跨平台」这三个字的环节。
5. JReleaser 发布流水线常见问题排查:六个踩坑记录
发布流水线本身不难,难的是第一次跑通之后遇到的各种环境问题。下面按踩坑频率整理六条,每条都是「现象 → 原因 → 解决」结构。
5.1 GitHub 403:token 权限不足以创建 Release
现象:JReleaser 执行到创建 Release 时报 403,日志里出现Resource not accessible by integration。原因:GitHub Actions 默认的GITHUB_TOKEN权限收缩,没有contents: write权限,无法创建 tag 和 release。解决:在 workflow 的permissions里显式声明contents: write;如果是在本地跑 JReleaser,则使用一个有 repo 权限的 Personal Access Token,以GITHUB_TOKEN环境变量传入。这个坑几乎每个团队都会踩一次,先检查权限再检查逻辑。
5.2 Tag 已存在导致发布失败
现象:重新跑流水线时,JReleaser 报tag already exists或 GitHub API 返回 422。原因:发布第一次成功生成v1.0.0,后续代码修改后依然使用同一个 tag 重跑,GitHub 不允许同名的 tag 指向新的 commit。解决:不要用overwrite: true覆盖旧 tag——这会让已发布的 Release 和源码 commit 对不上,后期排查问题时没有可信的版本锚点。正确做法是改版本号打新 tag,或者删掉旧 tag 和旧 Release 后重跑,这在 test 阶段很常见。
5.3 jpackage 在 CI 里找不到依赖工具
现象:Linux 上跑 deb 打包报错Invalid jpackage configuration: dpkg-deb not found,Windows 报 WiX 工具缺失。原因:jpackage 依赖系统打包工具,GitHub Actions 的 ubuntu-latest 默认不装 dpkg-deb,windows-latest 不装 WiX。解决:在 workflow 里加一步安装系统依赖:
- name: Install jpackage dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y dpkg-dev rpmWindows 上的 WiX 可以在 job 开头下载并加入 PATH,或者直接用--type exe避开 WiX 依赖,exe 安装程序的体积和 msi 差别不大,但配置少一圈。我的经验是:能用 exe 就用 exe,msi 的 WiX 版本兼容问题在 CI 里很容易占掉半天时间。
5.4 Homebrew Tap 更新失败,提示 401 或 422
现象:JReleaser 发布 GitHub Release 成功,但更新 homebrew-tap 仓库时失败。原因:Homebrew 渠道需要对 tap 仓库有写权限,而GITHUB_TOKEN的权限范围并不包含所有仓库。解决:在release步骤中使用一个 PAT,并且只给这个 PAT 配置目标 tap 仓库的写权限;不要用团队主账号的 token,因为 Homebrew 提交记录里会暴露账号信息。另外注意,JReleaser 默认会读取 artifactory 的 SHA-256 计算值写进 Formula,如果你在上传 tar.gz 之后又手动改过文件,校验值就会不匹配,同一个 Release 里不要对产物做二次编辑。
5.5 邮件通知渠道导致整个发布失败
现象:发布主流程已经成功,但配置了邮件 announce 的团队会遇到整个任务因为 SMTP 连接超时而失败。原因:JReleaser 的announce是一组独立动作,它的失败默认不会回滚已经完成的 Release,但如果配置错误太多,CI 会返回非零退出码。解决:第一次配置时把 announce 全部注释掉,跑通核心链路后再逐步放开。我一般只保留 webhook announce(比如企业微信或钉钉机器人),邮件服务在 CI 里配置 SMTP 的维护成本高于价值。
5.6 构建缓存污染:jpackage 使用了旧版本依赖
现象:本地打包正常,CI 打完的产物运行后行为不符合预期,检查后发现依赖版本不是最新的。原因:Maven 或 Gradle 的缓存目录被 CI 缓存住了,mvn package没有重新解析新的 SNAPSHOT 依赖。解决:发布流水线不要启用依赖缓存,或在打包步骤前加mvn -U强制更新快照。发布是要生成一次不可变快照的,和日常开发时享受缓存的策略正好相反。这里是少数「越慢越安全」的场景。
6. 让发布流水线更接近生产环境:从 dryRun 到灰度与回滚
流水线跑通后的下一步,是把它打磨到可以长期维护的状态。我强烈建议在本地执行一次 dryRun 验证配置再推向 CI。JReleaser 提供--dry-run参数,它会在本地模拟整个发布过程并打印将要执行的动作,但不真正上传文件、不创建 tag、不发请求。本地验证命令:
jbang jreleaser@1.13.0 release --dry-run --auto-config执行后检查日志里列出的 artifacts 路径是否都有真实文件、解析出的 tag 是否是期望的版本号、changelog 生成的 commit 列表是否和预期一致。这一步能省掉 CI 上第一次发布的失败循环——这条路径我几乎每次发布前都会跑一遍,它相当于 Git 里的暂存区,让你在上传之前看一眼即将发生的事。
灰度发布场景下,JReleaser 的应对策略是提前打 pre-release。它支持配置 Release 为预发布,只把安装包给内部测试,不加 Homebrew 渠道,等稳定后再打正式版本。实现方式是配置release.github.preRelease: true,或者直接用一个开发版本号比如1.1.0-RC1作为project.version——JReleaser 会自动把它发布为预发布版本。正式版本的发布路径不变。这个方法在开源项目里很常见,也适合企业内部验证 Windows 和 macOS 安装包在真实用户机器上的表现。
回滚是发布系统里绕不开的话题。JReleaser 本身不提供一键回滚命令,因为它不追踪部署状态——它只负责把文件发给渠道。通用的做法是:如果发布后发现严重问题,保留当前 Release 不动,新打一个修复版本 v1.0.1,让用户和自动更新机制自动拉取新版本。这比自己删除 Release 再重传 v1.0.0 要安全得多,后者会让已经安装了 v1.0.0 的用户永远不知道有修复版。我经历过一次因为删 Release 引发的用户困惑:用户在 GitHub 上找不到旧版本的下载入口,私信来问了好几天。之后我就把「历史版本不删、只追加补丁」写进了发布规范。
现在这套流程已经成为我接手所有 JVM 项目的第一件事:本地跑通 jpackage、写好.jreleaser.yml、把 workflow 放进去、第一次发布全程盯着日志。最开始会慢,因为要处理每个平台的细节差异;但跑通一次之后,后续每个版本的发布时间基本压缩到一条 CI 的构建时长以内。用 JReleaser 维护发布流水线的意义不在省掉那次打包的时间,而在于把发布这个高风险动作变成可重复、可审计、不依赖某个人记得怎么做的流程。希望这篇笔记能让你少走一圈我当时的弯路。
本文还有配套的精品资源,点击获取