如果你的构建服务器上某一层日志长这样——Building in workspace D:\Jenkins\workspace\SampleProject,再往下的某一步是这个项目的最后一公里:把一个jar包、几个配置文件和一段启动脚本卷成一个Windows能直接双击安装的exe。那么你手里大概率已经捏着一份.nsi文件了。NSIS(Nullsoft Scriptable Install System)在Windows安装包领域是个轻量老将,.nsi就是它的脚本源码,表面看着像配置文件,实际上是一门自带预处理器、函数、宏和页面控制的完整脚本语言。很多同学第一次接触它是在本地手搓安装包,但一旦把这个.nsi文件交给Jenkins去跑,难点就从“怎么写NSIS”变成了“怎么在CI环境里让NSIS稳定、可复现地出包”。
这篇文章就围绕着“Jenkins + Sample.nsi”这个组合,把NSIS脚本结构、Jenkins的集成方式、一个完整的Java项目打包案例,以及我在实盘维护中踩过的坑全部摊开来讲。无论你是刚把Jenkins跑通、准备接Windows安装包产出的新手,还是已经被makensis的报错日志折磨过的老手,这篇都值得从头到尾看一遍。
1. 先搞清楚治理对象:NSIS脚本和.nsi文件到底是什么
1.1 NSIS是什么,为什么CI环境里需要一份安装脚本
NSIS的全称是Nullsoft Scriptable Install System,最早由Nullsoft团队开发,是Windows平台上一个历史很长、分发很广的开源安装程序制作工具。它最核心的概念是:安装界面是用“脚本”描述出来的,不是用图形界面编辑器点出来的。这个脚本就是.nsi文件,编译它只需要一个命令行工具makensis.exe,没有任何IDE锁定的依赖。
正因为它脚本化、无锁定、命令行友好,所以特别适合放进自动化流水线里。你在Jenkins里把源码拉下来、用Maven或Gradle打出可执行产物,最后一步就是调用makensis把产物和配套资源封装成setup.exe。这里的.nsi文件本质上是一个带模板性质的可编程封装描述,里面可以写版本号、注册表操作、快捷方式创建、卸载逻辑、环境变量写入,甚至可以在安装过程中跑一段自定义逻辑。手动环境下很多人拿它打一个“下一步下一步”的安装包,够用了;但在Jenkins环境里,这份.nsi还承担了“交付边界定义”的作用:每次构建出什么文件、安装到哪个目录、写哪些注册表项、如何卸载,都应该记录在脚本里,跟着代码库一起走。
我见过不少团队一开始没有把.nsi纳入版本管理,产出的exe哪天坏了,根本说不清是用哪个版本的脚本打出来的。所以这篇里所有示例都以“Sample.nsi放在项目仓库的scripts目录下、随代码一起走”为前提,这本身就是CI环境下用NSIS的第一个最佳实践。
1.2 一段Sample.nsi的核心结构拆解
先看一个最典型的、能直接编译出Windows安装包的.nsi文件:
; 基础元数据 Name "SampleApp" OutFile "SampleApp-${VERSION}-setup.exe" InstallDir "$PROGRAMFILES\SampleApp" RequestExecutionLevel admin ; 向导页面 Page license Page directory Page instfiles UninstPage uninstConfirm UninstPage instfiles ; 安装段 Section "MainSection" SEC01 SetOutPath "$INSTDIR" File "target\sample-app.jar" File "config\application.yml" WriteUninstaller "$INSTDIR\uninstall.exe" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" \ "DisplayName" "SampleApp" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" \ "UninstallString" '"$INSTDIR\uninstall.exe"' SectionEnd ; 卸载段 Section "Uninstall" Delete "$INSTDIR\*.*" RMDir "$INSTDIR" SectionEnd逐段解释一下各部分在做什么。
Name这一行是安装程序窗口和开始菜单里显示的应用名,和安装完成后真正写进系统里的服务名、进程名关系不大。OutFile指定生成的安装包文件名,这里用了${VERSION},这是一个NSIS宏占位符,后面在Jenkins里可以通过/DVERSION=1.2.3这种形式在编译时覆盖。InstallDir是安装向导里默认展示的目录,注意$PROGRAMFILES在64位系统上会根据安装程序是否声明为64位自动重定向,懒省事的话在脚本里统一用$PROGRAMFILES64更不容易出错。
Page系列指令控制安装向导显示哪些界面:license是指定一份许可协议文件(要在脚本里用LicenseText或LicenseData指明具体文件),directory允许用户选择安装目录,instfiles是真正执行安装动作的进度页。卸载也对应两个页面。
Section是安装动作的核心容器。安装时先SetOutPath切到目标目录,然后File把构建产物或资源文件拷贝进去,WriteUninstaller生成卸载程序,接着写卸载相关的注册表项,这样用户才能在“控制面板-程序和功能”里看到并卸载这个应用。卸载段做的事情正好相反:删文件、删目录。
这里有一个非常重要的点:.nsi文件不是“声明式配置文件”,它的Section是按顺序执行的命令脚本。这一点和Ansible、Dockerfile这些声明式工具有本质区别,意味着你在Section里写的每一条指令的执行顺序会影响最终安装结果,要在心里把NSIS当成一门真正的小型编程语言。
2. Jenkins里接NSIS,方案选型与前置准备
2.1 freestyle Job还是Pipeline:安装包流水线怎么选
Jenkins接入.nsi打包,最朴素的做法是在freestyle Job里添加一个“执行Windows批处理命令”的构建步骤,然后直接调makensis。这种方法在验证脚本、临时跑通时有价值,但有明显问题:构建脚本散落在Jenkins Job配置里,没有版本控制,无法在代码评审里被审查,出问题回溯成本高。对于一个要交付客户的安装包,我建议直接用Pipeline,把整个流程写成Jenkinsfile,纳入代码仓库。这样任何时候都能知道是哪个版本的分支、哪个版本号的.nsi脚本产出了这个exe,审计和排障都能省一大半力气。
Pipeline的定义方式有两种:Declarative Pipeline和Scripted Pipeline。Declarative结构清晰、默认带stage隔离和超时控制,是团队协作时的首选;Scripted更灵活,适合动态生成step、深度控制流程的场景,但对编写者的Groovy熟练度有要求。下面这个案例用的是Declarative,因为它的可读性好,新同学接手也能很快看明白哪个stage在干嘛。
还有一点要考虑的:打包Windows安装包这件事,最稳的节点就是Windows节点。如果Jenkins Master跑在Linux上,不建议在同机通过Wine跑makensis,那会引入一堆兼容性和权限问题。更合理的做法是用Windows Agent节点,Pipeline里直接声明agent { label 'windows' },保证整个构建链路(Maven打包、NSIS封装)都在同一类操作系统环境下完成。
2.2 Windows构建节点的环境准备
在Windows Agent上需要安装NSIS。这里有个版本提醒:务必安装NSIS 3.x,不要再用NSIS 2.x。NSIS 3.x对Unicode支持、64位注册表处理、Python/Node等外部工具的集成都更完善,许多现代插件也已放弃对2.x的兼容。安装时选上“Add NSIS to PATH”选项,让makensis.exe能直接被命令行找到;如果没选,就要手动把C:\Program Files (x86)\NSIS这个目录加到系统PATH里。装完之后在命令行验证:
makensis /VERSION能输出版本号就说明环境OK。但要注意,Jenkins服务进程运行时使用的是“服务账户”的环境,而不是你当前登录用户的环境。如果你手动装NSIS时把它加到了用户级PATH,而Jenkins是以SYSTEM或某个服务账户运行的,它照样会报“makensis 不是内部或外部命令”。这种情况下要在“系统环境变量”里添加,并保证Jenkins重启后生效。
除了NSIS,Windows构建节点上还应该准备好JDK、Maven,并确认它们在Jenkins全局工具配置里被正确注册。做Java项目安装包时,常见配置是这样:JDK用OpenJDK 8或11(取决于项目基线),Maven用3.6.x以上版本,Node节点打上windows标签,流水线写agent { label 'windows' }。
另一个准备工作是决定怎么把版本号传进.nsi。有三种常见姿势:
- 在
.nsi里写死!define VERSION "1.0.0":最省事,但每次发版都要改脚本。如果脚本本身只是某个发布分支用的模板,可以接受。 - 在Jenkins里通过
/DVERSION=xxx在命令行覆盖:.nsi里写!ifndef VERSION兜底,Jenkins侧用/DVERSION强制注入,一劳永逸。 - 在Pipeline里先用
readMavenPom读取pom.xml里的版本,再拼接/DVERSION参数传给makensis。这是最推荐的做法,版本号只维护在项目构建文件里,不会出现“pom和nsi版本对不上”的问题。
3. 一个完整的打包案例:Java项目自动出Windows安装包
3.1 准备一个用来演示的微型Java项目
为了把整个链路讲得具体可复现,我假设项目就是一个最简单的Spring Boot应用,构建后产出target/sample-app.jar,另外还有一个config/application.yml作为运行时配置。项目目录结构如下:
sample-app/ ├── pom.xml ├── config/ │ └── application.yml ├── scripts/ │ └── Sample.nsi └── src/ └── main/ ├── java/... └── resources/...pom.xml里正常配置Spring Boot插件,打包时将依赖一起打进一个可执行jar。config/application.yml在安装时拷贝到安装目录下,方便用户在服务器上改端口、数据库地址之类的配置。
3.2 编写真正能跑通的Sample.nsi
这份脚本要比前面的示例完整许多,目标是覆盖安装包交付时的常见需求:版本号注入、64位目录、开始菜单快捷方式、控制面板卸载入口、以及一个可选的服务安装提示。
Unicode true !include "MUI2.nsh" !include "Sections.nsh" !ifndef VERSION !define VERSION "1.0.0" !endif Name "SampleApp" OutFile "SampleApp-${VERSION}-setup.exe" InstallDir "$PROGRAMFILES64\SampleApp" RequestExecutionLevel admin ; 使用现代用户界面 !define MUI_ABORTWARNING !define MUI_ICON "icon.ico" !insertmacro MUI_PAGE_LICENSE "LICENSE.txt" !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_LANGUAGE "SimpChinese" ; ========== 安装段 ========== Section "核心程序" SEC_MAIN SetOutPath "$INSTDIR" File "target\sample-app.jar" File /r "config" WriteUninstaller "$INSTDIR\uninstall.exe" ; 创建开始菜单快捷方式 CreateDirectory "$SMPROGRAMS\SampleApp" CreateShortcut "$SMPROGRAMS\SampleApp\SampleApp.lnk" "$INSTDIR\sample-app.jar" CreateShortcut "$SMPROGRAMS\SampleApp\卸载SampleApp.lnk" "$INSTDIR\uninstall.exe" ; 写入卸载注册表 WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" \ "DisplayName" "SampleApp" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" \ "DisplayVersion" "${VERSION}" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" \ "Publisher" "YourCompany" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" \ "UninstallString" '"$INSTDIR\uninstall.exe"' WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" \ "NoModify" 1 WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" \ "NoRepair" 1 SectionEnd ; ========== 卸载段 ========== Section "Uninstall" Delete "$INSTDIR\sample-app.jar" Delete "$INSTDIR\config\*.*" RMDir "$INSTDIR\config" Delete "$INSTDIR\uninstall.exe" RMDir "$INSTDIR" Delete "$SMPROGRAMS\SampleApp\SampleApp.lnk" Delete "$SMPROGRAMS\SampleApp\卸载SampleApp.lnk" RMDir "$SMPROGRAMS\SampleApp" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\SampleApp" SectionEnd这份脚本里我用了MUI2.nsh(Modern UI 2),它是NSIS自带的界面库,能生成更美观的向导页面。!define MUI_LANGUAGE "SimpChinese"决定了界面语言是简体中文,如果你的用户群混用中英文环境,还可以用!insertmacro MUI_LANGUAGE "English"做多语言切换。
File /r "config"表示递归拷贝整个config目录。这里有个细节:NSIS的File指令是相对于.nsi文件所在目录解析路径的,而不是相对于你当前命令行的工作目录。如果.nsi在scripts目录下,而target在项目根目录下,编译时就要用File "..\target\sample-app.jar",或者提前在Jenkins命令里把当前目录切到项目根目录。
我特意没有在脚本里写死OutFile路径中的版本号以外的部分,这样Jenkins每次构建时通过/DVERSION传入版本号,产出的exe文件名天然带版本,非常方便归档和追溯。
3.3 Jenkins Pipeline集成实现自动打包
下面是一份可以直接放进项目根目录的Jenkinsfile,配合上面的.nsi使用:
pipeline { agent { label 'windows' } stages { stage('Checkout') { steps { checkout scm } } stage('Maven Build') { steps { bat 'mvn clean package -DskipTests' } } stage('NSIS Package') { steps { script { def pom = readMavenPom file: 'pom.xml' def version = pom.version echo "Packaging version ${version}" bat "makensis /DVERSION=${version} scripts\\Sample.nsi" } } } stage('Archive Installer') { steps { archiveArtifacts artifacts: 'SampleApp-*-setup.exe', allowEmptyArchive: false } } } }几个关键的细节:
readMavenPom是Pipeline自带能力,不需要额外插件,直接读取pom.xml里的版本号,省去手工在Jenkins里配置参数或将版本写死的步骤。这一行非常重要:它保证了版本号只在pom.xml里维护,.nsi里的!ifndef VERSION只是在本地手动编译时兜底。
bat步骤里的${version}是Groovy字符串插值,实际执行时会变成类似makensis /DVERSION=2.3.1 scripts\Sample.nsi的命令。Windows下路径分隔符用反斜杠,所以在Groovy字符串里写成scripts\\Sample.nsi。
archiveArtifacts这一步是把最终生成的exe上传到Jenkins的任务页面,方便测试和发布同学直接下载。注意通配符是SampleApp-*-setup.exe,这恰好匹配.nsi里OutFile "SampleApp-${VERSION}-setup.exe"生成的文件名格式。
还需要在Jenkins全局工具配置里添加Maven和JDK。Pipeline里没有显式写tools段,那Jenkins会用系统PATH里的Maven和JDK;如果想让构建环境更可控、可重复,可以加上:
tools { maven 'Maven3' jdk 'JDK8' }提前在管理Jenkins -> 全局工具配置里定义一个名为Maven3和JDK8的工具链,Jenkins会自动下载并缓存,构建时自动激活对应的PATH。这样即使Windows节点上没装全局Maven,也能正常构建。
3.4 手动验证与Jenkins验证的衔接
在实际推送到Jenkins之前,建议先在Windows节点或本地命令行手动执行一次:
makensis /DVERSION=1.0.0 scripts\Sample.nsi如果这一步顺利产出SampleApp-1.0.0-setup.exe,再放到Jenkins里跑。手动验证和Jenkins验证有个常见差异:本机你可能有完全权限的交互桌面,但Jenkins服务运行在一个后台会话里,某些UAC弹窗、交互对话框根本不会显示。NSIS脚本里如果写了MessageBox这类需要用户交互的指令,在Jenkins构建中会挂死或直接失败。所以我在这份脚本里没有放任何交互弹窗,安装逻辑全部静默可执行,这一点在CI环境里尤其重要。
Jenkins上首次跑通后,去Archive Installer的产物区能看到exe,下载双击安装,再在控制面板里卸载,走一遍完整生命周期,确认注册表、快捷方式、配置目录都符合预期。这一套闭环验证下来,安装包才算真正达到可交付状态。
4. 常见问题与排查技巧实录
4.1 “makensis不是内部或外部命令”:服务账户的环境变量坑
这是Jenkins接NSIS时最高频的报错。原因通常不是NSIS没装,而是Jenkins服务账户的环境变量不包含makensis所在的目录。手动装NSIS时“Add NSIS to PATH”写入的往往是当前用户的环境变量,而Jenkins服务以SYSTEM或独立服务账户运行,读不到用户级PATH。
排查方式很简单:在任意Pipeline里加一步:
bat 'where makensis'如果提示找不到,就在系统属性 -> 高级系统设置 -> 环境变量 -> 系统变量里,把C:\Program Files (x86)\NSIS追加到Path,然后重启Jenkins服务(或者重启Master与Agent的连接)。不要只改用户变量,那对服务账户通常无效。
当然,也有一种更彻底的方案:在Pipeline里直接用绝对路径调用,比如bat '"C:\\Program Files (x86)\\NSIS\\makensis.exe" /DVERSION=${version} scripts\\Sample.nsi'。这样绕开PATH的依赖,适合你没有系统环境变量修改权限的受限场景。
4.2 中文乱码、编码和版本兼容性
.nsi文件里的中文字段,比如应用名称、安装界面文字,容易在安装时显示成乱码或导致编译报错。原因多半是文件编码和NSIS对Unicode的处理不匹配。
解决方式有两条:
- 在脚本第一行写
Unicode true,并且把.nsi文件保存为UTF-8 with BOM,或者UTF-16 LE。NSIS 3.x对Unicode支持完善,这样中文字符串能正常编译和显示。 - 尽量不要在
.nsi里写大量中文字符串,尤其是需要多语言支持的场景,把界面文案抽到Language文件或.nsh语言文件里,保持脚本主文件ASCII干净。
另外,NSIS 3.x对某些老指令的解析更严格,如果你的项目里有一些从网上下载的历史脚本,可能要用NSIS 2.x兼容模式或修改指令代码。我个人建议新项目一律用NSIS 3.x,不为其他,就为UTF-8和64位注册表重定向这两个刚需功能。
4.3 文件找不到:工作目录和相对路径的迷思
报错长这样:
File: "target\sample-app.jar" -> no files found.很多同学的第一个反应是项目没构建成功,但实际上多半是NSIS解析File指令的基准目录不是项目根目录。NSIS的File指令是相对于.nsi文件所在目录解析的,如果你的.nsi放在scripts下,那么File "target\sample-app.jar"会在scripts/target里找文件,自然找不到。
解决方式有两种:
- 在
.nsi里用相对路径时写成File "..\\target\\sample-app.jar" - 更推荐在Jenkins的
bat步骤里,先cd到项目根目录,再调用makensis,这样.nsi里的路径仍然以它所在目录为基准,但File里的相对路径写法更直观
还需要注意的是,makensis本身支持/CD参数切换到脚本所在目录,用makensis /CD /DVERSION=... scripts\Sample.nsi也能缓解目录混淆问题,但逻辑上不如第三种直观。
4.4 杀毒软件误报和文件占用
Windows环境下,NSIS打出来的安装包经常会被杀毒软件误报。这并不代表安装包有毒,而是因为NSIS的安装包结构包含自解压逻辑,某些杀软启发式引擎会对这类特征敏感。
减轻误报的方法:
- 在CI机的杀软设置里,把Jenkins的workspace目录加入排除列表,避免每次构建时杀软扫描“正在生成”的exe导致文件占用或误报。
- 对正式交付的安装包做数字签名。无论是购买代码签名证书还是用组织内的证书,签名之后误报概率大幅下降,而且客户在部署时信任度更高。
- 尽量通过NSIS官方渠道下载安装NSIS,不要使用来路不明的修改版或精简版,这类版本更容易触发杀软警报。
文件占用的典型现象是:构建执行到makensis时,提示无法写入OutFile指定的exe文件,或者脚本里Delete某个临时文件时提示“Permission denied”。排查时先确认没有手工双击运行上一个版本的安装包,同时检查杀软实时防护是否有扫描占用。把workspace和输出目录加入排除项后,这个问题基本能消停。
4.5 安装包在客户端安装时“点了没反应”
构建机上安装包跑得好好的,到了客户Windows Server上双击却没有任何反应。这类问题多半和权限、系统版本有关。
我在实际项目里遇到过两种情况:一是.nsi里用了RequestExecutionLevel user,但安装逻辑里又要向$PROGRAMFILES64写文件,普通用户根本无权限,程序没有任何反馈直接退出。解决办法是声明为admin,并在安装向导里加ABORTWARNING提示。二是客户机器是Server Core或精简系统,缺少某些图形界面组件,安装向导展示页面时异常退出。这种情况往往不常见,但如果发生,可以在软件发布说明里标注建议使用完整版桌面系统。
通用避坑建议:把NSIS脚本的编译错误和运行错误分开排查。编译阶段报错基本是语法、路径、编码问题;安装运行阶段没有反馈,不要急着改脚本,先用Process Monitor或安装日志把问题定位清楚。NSIS也可以开启安装日志:
!define MUI_FINISHPAGE_RUN_TEXT "查看安装日志"或者直接在脚本里用DetailPrint输出关键步骤,Jenkins控制台和客户端安装过程都能看到。这样即使客户环境出问题,也能凭借日志快速定位。
5. 把.nsi变成团队基础设施的一份子
到这里,Jenkins配合.nsi打Windows安装包的完整链路过了一遍。最后再分享几个我维护这类流水线时觉得最有价值的习惯。
第一个习惯:把.nsi和.nsh这类脚本纳入代码仓库,和源码同版本管理。安装脚本不是某个人的“个人玩具”,它是交付物的一部分,改过什么版本、配套哪个源码分支,都应该能追溯到。我见过太多团队在Jenkins上出一个新exe,但根本说不清是用哪份.nsi打的,一出问题就全team加班。
第二个习惯:在每次构建的归档产物里,除了上传exe,再上传一个MD5校验文件。安装包经常要跨部门传递、上传到客户服务器,一个SampleApp-1.0.0-setup.exe.md5文件能让对方在部署前快速校验文件完整性,也方便自己排查传输过程中是否丢包。实现方式就是在Pipeline里加一步:
bat "certutil -hashfile SampleApp-1.0.0-setup.exe MD5 > SampleApp-setup.exe.md5" archiveArtifacts artifacts: 'SampleApp-*.exe*', allowEmptyArchive: false第三个习惯:定期在干净的Windows虚拟机或容器里做一次冷启动验证。用一台全新的、没装过JDK和Maven的机器跑一遍打包流水线,能及时暴露出那些“本机能过、别人机器就不行”的隐性问题。虽然现代CI环境都倾向用Docker保证一致性,但对于NSIS这种老派Windows工具链,做一次冷启动验证的成本很低,收益却很高——它逼着你把依赖全部显式声明出来。
每次看到makensis在Jenkins控制台里刷刷刷打出编译日志,最后输出一行“Process exit code: 0”的时候,我都有种过了道关的轻松感。安装包这最后一公里,看似只是几个脚本指令的拼接,真正把它变成团队里没人愿意碰的脏活,还是能让所有人下班时间提前半小时的。希望这篇把NSIS在Jenkins里的那些坑都提前给你踩平了。