diff --git a/.github/workflows/release_linux.yml b/.github/workflows/release_linux.yml index 4a675f6a0f..7b7e00cca0 100644 --- a/.github/workflows/release_linux.yml +++ b/.github/workflows/release_linux.yml @@ -19,12 +19,17 @@ concurrency: cancel-in-progress: true env: - FEDORA_IMAGE: fedora:44 # pinned for reproducible packaging; bump deliberately + # Container image pins live in packaging/images.env (shared with + # packaging/test-local.sh) and are sourced by the steps that need them. + # Debian's builder also runs a full cargo build inside debian:12 in + # packaging/test-local.sh. jobs: build: runs-on: ubuntu-22.04 timeout-minutes: 180 + permissions: + contents: read permissions: contents: read strategy: @@ -36,6 +41,7 @@ jobs: with: submodules: recursive fetch-depth: 0 # git describe needs history for the dispatch fallback version + persist-credentials: false # the checkout is only built, never pushed # Register qemu binfmt handlers so `docker run --platform linux/arm64` # works on the arm64 matrix leg (needed for the debian:12 deb builder). @@ -52,6 +58,12 @@ jobs: if [ -z "$VERSION" ]; then VERSION="0.0.0-ci" fi + # Validate the grammar up front, so a non-semver tag fails here with a clear message + # instead of deep inside a packaging script (the same grammar the builders enforce). + if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "derived version '$VERSION' is not X.Y.Z[-suffix]; tag a semver release" >&2 + exit 1 + fi echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "Version extracted: $VERSION" @@ -121,18 +133,20 @@ jobs: # runs the deb builder inside an arm64 debian:12 container. The same # scripts run locally via packaging/test-local.sh. - name: Create Debian packages + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} run: | ARCH=${{ matrix.arch }} if [ "$ARCH" = "x86_64" ]; then DEBARCH=amd64; else DEBARCH=arm64; fi docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work debian:12 \ packaging/deb/build.sh \ - --package node --version "${{ steps.get_version.outputs.VERSION }}" \ + --package node --version "$VERSION" \ --debarch $DEBARCH \ --binaries-dir /work/target/$ARCH-unknown-linux-gnu/release \ --out /work/dist docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work debian:12 \ packaging/deb/build.sh \ - --package gui --version "${{ steps.get_version.outputs.VERSION }}" \ + --package gui --version "$VERSION" \ --debarch $DEBARCH \ --gui-binary /work/target/$ARCH-unknown-linux-gnu/release/node-gui \ --repo-root /work --out /work/dist @@ -140,53 +154,104 @@ jobs: # Arch-matched container (qemu on the arm64 leg): native strip works and # the binaries run under emulation so help2man generates real man pages. - name: Create RPM packages + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} run: | + source packaging/images.env ARCH=${{ matrix.arch }} - docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work ${{ env.FEDORA_IMAGE }} \ + docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work $FEDORA_IMAGE \ packaging/rpm/build.sh \ --package node --rpmarch $ARCH \ - --version "${{ steps.get_version.outputs.VERSION }}" \ + --version "$VERSION" \ --binaries-dir /work/target/$ARCH-unknown-linux-gnu/release \ --out /work/dist - docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work ${{ env.FEDORA_IMAGE }} \ + docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work $FEDORA_IMAGE \ packaging/rpm/build.sh \ --package gui --rpmarch $ARCH \ - --version "${{ steps.get_version.outputs.VERSION }}" \ + --version "$VERSION" \ --gui-binary /work/target/$ARCH-unknown-linux-gnu/release/node-gui \ --repo-root /work --out /work/dist # Install smoke tests in fresh containers (same images as production use) - name: Smoke test Debian packages + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} run: | ARCH=${{ matrix.arch }} if [ "$ARCH" = "x86_64" ]; then DEBARCH=amd64; else DEBARCH=arm64; fi docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work debian:12 \ - packaging/checks/smoke-deb.sh dist/Mintlayer_Node_linux_${{ steps.get_version.outputs.VERSION }}_${DEBARCH}.deb mintlayer-node node + packaging/checks/smoke-deb.sh dist/Mintlayer_Node_linux_${VERSION}_${DEBARCH}.deb mintlayer-node node docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work debian:12 \ - packaging/checks/smoke-deb.sh dist/Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${DEBARCH}.deb mintlayer-node-gui gui + packaging/checks/smoke-deb.sh dist/Mintlayer_Node_GUI_linux_${VERSION}_${DEBARCH}.deb mintlayer-node-gui gui - name: Smoke test RPM packages + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} run: | ARCH=${{ matrix.arch }} - docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work ${{ env.FEDORA_IMAGE }} \ - packaging/checks/smoke-rpm.sh dist/Mintlayer_Node_linux_${{ steps.get_version.outputs.VERSION }}_${ARCH}.rpm mintlayer-node node - docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work ${{ env.FEDORA_IMAGE }} \ - packaging/checks/smoke-rpm.sh dist/Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${ARCH}.rpm mintlayer-node-gui gui + docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work $FEDORA_IMAGE \ + packaging/checks/smoke-rpm.sh dist/Mintlayer_Node_linux_${VERSION}_${ARCH}.rpm mintlayer-node node + docker run --rm --platform linux/$ARCH -v "$PWD":/work -w /work $FEDORA_IMAGE \ + packaging/checks/smoke-rpm.sh dist/Mintlayer_Node_GUI_linux_${VERSION}_${ARCH}.rpm mintlayer-node-gui gui + + # Arch images are amd64-only: the x86_64 leg is arch-matched (native + # strip, ldd-based dependencies, help2man man pages), the arm64 leg + # repackages cross-target with stubs — same as the local rpm legs. + - name: Create Arch packages + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} + run: | + source packaging/images.env + ARCH=${{ matrix.arch }} + docker run --rm -v "$PWD":/work -w /work $ARCH_IMAGE \ + packaging/arch/build.sh \ + --package node --arch "$ARCH" \ + --version "$VERSION" \ + --binaries-dir /work/target/$ARCH-unknown-linux-gnu/release \ + --out /work/dist + docker run --rm -v "$PWD":/work -w /work $ARCH_IMAGE \ + packaging/arch/build.sh \ + --package gui --arch "$ARCH" \ + --version "$VERSION" \ + --gui-binary /work/target/$ARCH-unknown-linux-gnu/release/node-gui \ + --repo-root /work --out /work/dist + + # x86_64 leg: arch-matched container (native strip, ldd-based + # dependencies, help2man man pages), so the package installs natively. + # aarch64 leg: the same amd64-only image installs the foreign-arch + # package with IgnoreArch and runs the binaries through the qemu binfmt + # handlers registered above — the same emulation the deb/rpm legs use + # for their arm64 smoke tests. + - name: Smoke test Arch packages + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} + run: | + source packaging/images.env + ARCH=${{ matrix.arch }} + docker run --rm -v "$PWD":/work -w /work $ARCH_IMAGE \ + packaging/checks/smoke-arch.sh dist/Mintlayer_Node_linux_${VERSION}_${ARCH}.pkg.tar.zst mintlayer-node node "$ARCH" + docker run --rm -v "$PWD":/work -w /work $ARCH_IMAGE \ + packaging/checks/smoke-arch.sh dist/Mintlayer_Node_GUI_linux_${VERSION}_${ARCH}.pkg.tar.zst mintlayer-node-gui gui "$ARCH" - name: Package Mintlayer Node (without GUI) as tar.gz + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} + BINARY_LIST: ${{ inputs.binary_list }} run: | - mkdir -p Mintlayer_Node_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }} - IFS=',' read -ra BINARIES <<< "${{ inputs.binary_list }}" + mkdir -p Mintlayer_Node_linux_${VERSION}_${{ matrix.arch }} + IFS=',' read -ra BINARIES <<< "$BINARY_LIST" for binary in "${BINARIES[@]}"; do - cp target/${{ matrix.arch }}-unknown-linux-gnu/release/$binary Mintlayer_Node_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}/mintlayer-$binary + cp target/${{ matrix.arch }}-unknown-linux-gnu/release/$binary Mintlayer_Node_linux_${VERSION}_${{ matrix.arch }}/mintlayer-$binary done - tar -czvf Mintlayer_Node_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}.tar.gz Mintlayer_Node_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }} + tar -czvf Mintlayer_Node_linux_${VERSION}_${{ matrix.arch }}.tar.gz Mintlayer_Node_linux_${VERSION}_${{ matrix.arch }} - name: Package Mintlayer Node GUI as tar.gz + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} run: | - mkdir -p Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }} - cp target/${{ matrix.arch }}-unknown-linux-gnu/release/node-gui Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}/mintlayer-node-gui - tar -czvf Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}.tar.gz Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }} + mkdir -p Mintlayer_Node_GUI_linux_${VERSION}_${{ matrix.arch }} + cp target/${{ matrix.arch }}-unknown-linux-gnu/release/node-gui Mintlayer_Node_GUI_linux_${VERSION}_${{ matrix.arch }}/mintlayer-node-gui + tar -czvf Mintlayer_Node_GUI_linux_${VERSION}_${{ matrix.arch }}.tar.gz Mintlayer_Node_GUI_linux_${VERSION}_${{ matrix.arch }} - name: Upload Node DEB Artifact (without GUI) uses: actions/upload-artifact@v4 @@ -212,6 +277,18 @@ jobs: name: Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}_rpm path: dist/Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}.rpm + - name: Upload Node PKG Artifact + uses: actions/upload-artifact@v4 + with: + name: Mintlayer_Node_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}_pkg + path: dist/Mintlayer_Node_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}.pkg.tar.zst + + - name: Upload GUI PKG Artifact + uses: actions/upload-artifact@v4 + with: + name: Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}_pkg + path: dist/Mintlayer_Node_GUI_linux_${{ steps.get_version.outputs.VERSION }}_${{ matrix.arch }}.pkg.tar.zst + - name: List tar.gz files run: | echo "Matching tar.gz files:" diff --git a/.github/workflows/release_windows.yml b/.github/workflows/release_windows.yml index ece19666b1..95d2a1b237 100644 --- a/.github/workflows/release_windows.yml +++ b/.github/workflows/release_windows.yml @@ -6,10 +6,22 @@ on: binary_list: required: true type: string + workflow_dispatch: + inputs: + binary_list: + description: 'Binaries bundled into the plain zip artifacts (the NSIS installers always ship the full set)' + required: false + type: string + default: 'api-blockchain-scanner-daemon,api-web-server,dns-server,node-daemon,wallet-address-generator,wallet-cli,wallet-rpc-daemon' jobs: build: runs-on: windows-latest + # Note: bounds the whole job, and in particular the installer smoke test below (every + # installed binary is executed with a per-process timeout as well; this is the backstop). + timeout-minutes: 120 + permissions: + contents: read env: # With the default CARGO_HOME the job will fail due to Windows path length limit when # checking out the trezor firmware repo. E.g. one of the paths looks like this: @@ -19,11 +31,26 @@ jobs: - uses: actions/checkout@v5 with: submodules: recursive + fetch-depth: 0 # git describe needs history for the dispatch fallback version + persist-credentials: false # the checkout is only built, never pushed - name: Extract version from tag id: get_version run: | $VERSION = $env:GITHUB_REF -replace 'refs/tags/', '' -replace '^v', '' + if ($VERSION -eq $env:GITHUB_REF) { + $VERSION = git describe --tags --abbrev=0 2>$null + $VERSION = $VERSION -replace '^v', '' + } + if ([string]::IsNullOrEmpty($VERSION)) { + $VERSION = "0.0.0-ci" + } + # Validate the grammar up front, so a non-semver tag fails here with a clear message + # instead of deep inside a packaging script (the same grammar the NSIS tooling and the + # Linux builders enforce). + if ($VERSION -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$') { + throw "derived version '$VERSION' is not X.Y.Z[-suffix]; tag a semver release" + } echo "VERSION=$VERSION" >> $env:GITHUB_OUTPUT echo "Version extracted: $VERSION" shell: pwsh @@ -36,12 +63,17 @@ jobs: - name: Build Mintlayer Node and GUI run: cargo build --release --locked --features trezor,ledger + # Note: the version (and the workflow input) are passed through environment + # variables instead of direct ${{ }} interpolation into the run blocks, so + # that a crafted tag/ref/input cannot inject shell into the steps. - name: Package Mintlayer Node + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} + BINARY_LIST: ${{ inputs.binary_list }} run: | - $VERSION = "${{ steps.get_version.outputs.VERSION }}" - $DEST = "Mintlayer_Node_win_${VERSION}" + $DEST = "Mintlayer_Node_win_$env:VERSION" New-Item -ItemType Directory -Path $DEST - $binary_list = "${{ inputs.binary_list }}" -split ',' | Where-Object { $_ -ne "node-gui" } + $binary_list = $env:BINARY_LIST -split ',' | Where-Object { $_ -ne "node-gui" } foreach ($binary in $binary_list) { $binary = $binary.Trim() if (Test-Path "target\release\$binary.exe") { @@ -63,26 +95,25 @@ jobs: run: .\build-tools\win\create-license.ps1 shell: pwsh - - name: Create NSIS Installer Script - run: .\build-tools\win\create-nsis-script.ps1 -Version "${{ steps.get_version.outputs.VERSION }}" + - name: Create NSIS installers (node + GUI) + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} + run: .\build-tools\win\create-nsis-installers.ps1 -Version $env:VERSION shell: pwsh - - name: Build NSIS Installer + - name: Smoke test installers (silent install/uninstall) + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} run: | - $makensisPath = (Get-Command makensis.exe).Source - Write-Host "Using makensis at: $makensisPath" - & $makensisPath installer.nsi - shell: pwsh - - - name: Display NSIS Script - run: | - Get-Content -Path installer.nsi + .\build-tools\win\smoke-install.ps1 -Installer "Mintlayer_Node_win_${env:VERSION}_Setup.exe" -AppName "Mintlayer Node" -Kind node -Version $env:VERSION + .\build-tools\win\smoke-install.ps1 -Installer "Mintlayer_Node_GUI_win_${env:VERSION}_Setup.exe" -AppName "Mintlayer Node GUI" -Kind gui -Version $env:VERSION shell: pwsh - name: Package Mintlayer Node GUI + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} run: | - $VERSION = "${{ steps.get_version.outputs.VERSION }}" - $DEST = "Mintlayer_Node_GUI_win_${VERSION}" + $DEST = "Mintlayer_Node_GUI_win_$env:VERSION" New-Item -ItemType Directory -Path $DEST Copy-Item "target\release\node-gui.exe" -Destination $DEST Compress-Archive -Path $DEST -DestinationPath "${DEST}.zip" @@ -100,7 +131,13 @@ jobs: name: Mintlayer_Node_GUI_win_${{ steps.get_version.outputs.VERSION }} path: Mintlayer_Node_GUI_win_${{ steps.get_version.outputs.VERSION }}.zip - - name: Upload NSIS Installer Artifact + - name: Upload Node NSIS Installer Artifact + uses: actions/upload-artifact@v4 + with: + name: Mintlayer_Node_win_${{ steps.get_version.outputs.VERSION }}_Setup + path: Mintlayer_Node_win_${{ steps.get_version.outputs.VERSION }}_Setup.exe + + - name: Upload GUI NSIS Installer Artifact uses: actions/upload-artifact@v4 with: name: Mintlayer_Node_GUI_win_${{ steps.get_version.outputs.VERSION }}_Setup diff --git a/.gitignore b/.gitignore index 46e0f1fa0e..ff2c0a517f 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,9 @@ build-tools/block-data-plots/output packaging/dist/ /dist/ target-debian/ + +# Rendered NSIS scripts and installers (build-tools/win/create-nsis-installers.ps1) +/installer-node.nsi +/installer-gui.nsi +/Mintlayer_Node_win_*_Setup.exe +/Mintlayer_Node_GUI_win_*_Setup.exe diff --git a/build-tools/win/create-nsis-installers.ps1 b/build-tools/win/create-nsis-installers.ps1 new file mode 100644 index 0000000000..dd9179b8b6 --- /dev/null +++ b/build-tools/win/create-nsis-installers.ps1 @@ -0,0 +1,95 @@ +# Renders the NSIS installer scripts from the templates in .\nsi\ and builds +# them with makensis. Produces: +# - Mintlayer_Node_win__Setup.exe (CLI daemons and tools) +# - Mintlayer_Node_GUI_win__Setup.exe (GUI) +# +# Requires: NSIS 3 on PATH (or in the default install location); LICENSE.txt +# (as produced by create-license.ps1) and the release binaries under +# target\release must exist before this script runs. + +param ( + [Parameter(Mandatory = $true)] + [string]$Version +) + +$ErrorActionPreference = "Stop" + +$TemplateDir = Join-Path $PSScriptRoot "nsi" + +# The version comes from the git tag and ends up inside NSIS string literals; +# restrict it to the same grammar the Linux packages enforce (X.Y.Z with an +# optional -suffix, see packaging/common/lib.sh) so it cannot break out of +# them and both package families ship from the same version format. +if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$') { + throw "invalid version '$Version' (expected X.Y.Z with an optional -suffix)" +} + +if (-not (Test-Path "LICENSE.txt")) { + throw "LICENSE.txt not found; run create-license.ps1 first" +} + +$makensis = Get-Command makensis.exe -ErrorAction SilentlyContinue +if ($null -ne $makensis) { + $makensisPath = $makensis.Source +} else { + $makensisPath = "C:\Program Files (x86)\NSIS\makensis.exe" +} +if (-not (Test-Path $makensisPath)) { + throw "makensis.exe not found on PATH or in the default NSIS location" +} +Write-Host "Using makensis at: $makensisPath" + +# The installer templates pack the binaries straight from target\release +# (the cargo release output this script expects to run against). +$requiredBinaries = @( + "node-daemon.exe", "wallet-rpc-daemon.exe", "api-web-server.exe", + "api-blockchain-scanner-daemon.exe", "dns-server.exe", "wallet-cli.exe", + "wallet-address-generator.exe", "node-gui.exe" +) +foreach ($exe in $requiredBinaries) { + $path = Join-Path "target\release" $exe + if (-not (Test-Path $path)) { + throw "required binary not found: $path" + } +} + +function Build-Installer { + param ( + [string]$TemplateName, + [string]$ScriptName + ) + + $template = Join-Path $TemplateDir $TemplateName + $script = Join-Path $PWD $ScriptName + + Write-Host "Rendering $script from $template (version $Version)" + $content = Get-Content -Raw -Path $template + $content = $content.Replace("@VERSION@", $Version) + # The templates pull in common.nsh from the template dir via + # !addincludedir, so the !include resolves regardless of where makensis + # runs. + $content = $content.Replace("@NSI_DIR@", $TemplateDir) + # Write ASCII without a BOM: the scripts are plain ASCII (keep the + # templates ASCII-only) and makensis is picky about BOMs. + [System.IO.File]::WriteAllText($script, $content, [System.Text.Encoding]::ASCII) + + Write-Host "Building $ScriptName" + & $makensisPath $script + # makensis exits 1 for warnings and 2 for errors; warnings are visible in + # the output but allowed (consistent with the lintian/rpmlint gates). + if ($LASTEXITCODE -ge 2) { + throw "makensis failed for $ScriptName (exit $LASTEXITCODE)" + } +} + +Build-Installer -TemplateName "node.nsi.in" -ScriptName "installer-node.nsi" +Build-Installer -TemplateName "gui.nsi.in" -ScriptName "installer-gui.nsi" + +$nodeOut = "Mintlayer_Node_win_${Version}_Setup.exe" +$guiOut = "Mintlayer_Node_GUI_win_${Version}_Setup.exe" +foreach ($out in @($nodeOut, $guiOut)) { + if (-not (Test-Path $out)) { + throw "installer was not produced: $out" + } + Write-Host "built $((Get-Item $out).Length) byte installer: $out" +} diff --git a/build-tools/win/create-nsis-script.ps1 b/build-tools/win/create-nsis-script.ps1 deleted file mode 100644 index d756537ca4..0000000000 --- a/build-tools/win/create-nsis-script.ps1 +++ /dev/null @@ -1,100 +0,0 @@ -# build-tools/win/create-nsis-script.ps1 - -param ( - [Parameter(Mandatory=$true)] - [string]$Version -) - -Write-Host "Debug: Received Version = $Version" - -$outFileName = "Mintlayer_Node_GUI_win_${Version}_Setup.exe" - -$NSIS_SCRIPT = @" -; Mintlayer Node GUI Installer Script -!define APPNAME "Mintlayer Node GUI" -!define COMPANYNAME "Mintlayer" -!define DESCRIPTION "Mintlayer Node GUI Application" -!define VERSION "${Version}" - -; Main Install settings -Name "`${APPNAME}" -InstallDir "`$PROGRAMFILES64\`${COMPANYNAME}\`${APPNAME}" -OutFile "$outFileName" - -; Modern interface settings -!include "MUI2.nsh" -!include "LogicLib.nsh" - -!define MUI_ABORTWARNING - -; License page -!insertmacro MUI_PAGE_LICENSE "LICENSE.txt" - -; Pages -!insertmacro MUI_PAGE_WELCOME -!insertmacro MUI_PAGE_DIRECTORY -!insertmacro MUI_PAGE_INSTFILES -!insertmacro MUI_PAGE_FINISH - -; Languages -!insertmacro MUI_LANGUAGE "English" - -Section "Install" - ; Debug output - DetailPrint "Debug: INSTDIR before check is '`$`INSTDIR'" - - ; Check if INSTDIR is empty and set a fallback if it is - `${If} "`$`INSTDIR" == "" - StrCpy `$`INSTDIR "`$`${PROGRAMFILES64}\`${COMPANYNAME}\`${APPNAME}" - DetailPrint "Debug: INSTDIR was empty, set to fallback: '`$`INSTDIR'" - `${EndIf} - - ; Set output path to the installation directory - SetOutPath "`$`INSTDIR" - - ; Add files - File "target\release\node-gui.exe" - File "LICENSE.txt" - - ; Create desktop shortcut - CreateShortcut "`$`DESKTOP\`${APPNAME}.lnk" "`$`INSTDIR\node-gui.exe" - - ; Create Start Menu shortcut - CreateDirectory "`${SMPROGRAMS}\`${COMPANYNAME}" - CreateShortcut "`${SMPROGRAMS}\`${COMPANYNAME}\`${APPNAME}.lnk" "`${INSTDIR}\node-gui.exe" - - ; Write the uninstall keys for Windows - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${COMPANYNAME} `${APPNAME}" "DisplayName" "`${APPNAME}" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${COMPANYNAME} `${APPNAME}" "UninstallString" '"`$`INSTDIR\uninstall.exe"' - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${COMPANYNAME} `${APPNAME}" "DisplayVersion" "`${VERSION}" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${COMPANYNAME} `${APPNAME}" "Publisher" "`${COMPANYNAME}" - WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${COMPANYNAME} `${APPNAME}" "NoModify" 1 - WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${COMPANYNAME} `${APPNAME}" "NoRepair" 1 - - ; Create uninstaller - WriteUninstaller "`$`INSTDIR\uninstall.exe" - -SectionEnd - -Section "Uninstall" - ; Remove files - Delete "`$`INSTDIR\node-gui.exe" - Delete "`$`INSTDIR\LICENSE.txt" - Delete "`$`INSTDIR\uninstall.exe" - - ; Remove shortcuts - Delete "`${DESKTOP}\`${APPNAME}.lnk" - Delete "`${SMPROGRAMS}\`${COMPANYNAME}\`${APPNAME}.lnk" - RMDir "`${SMPROGRAMS}\`${COMPANYNAME}" - - ; Remove directories used - RMDir "`$`INSTDIR" - - ; Remove uninstall registry keys - DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${COMPANYNAME} `${APPNAME}" -SectionEnd -"@ - -# Remove any potential UTF-8 BOM and ensure ASCII encoding -$NSIS_SCRIPT = $NSIS_SCRIPT.TrimStart([char]0xFEFF) -[System.IO.File]::WriteAllLines("installer.nsi", $NSIS_SCRIPT) diff --git a/build-tools/win/nsi/common.nsh b/build-tools/win/nsi/common.nsh new file mode 100644 index 0000000000..caffd5614b --- /dev/null +++ b/build-tools/win/nsi/common.nsh @@ -0,0 +1,57 @@ +; Shared NSIS macros for the Mintlayer installers. +; The rendered scripts include this file; create-nsis-installers.ps1 passes +; the template directory to makensis via /I so the bare name resolves. +; Pure ASCII only (the generator writes the rendered script as ASCII). + +; Silently remove a previously installed version of the product whose +; uninstall registry entries live under ${UNINSTKEY}. The stored +; UninstallString includes quotes, which must not be re-quoted, so they are +; stripped and the command line is rebuilt here; per the NSIS documentation +; the _?= parameter must come last and unquoted. +!macro MintlayerRemovePreviousInstall + ReadRegStr $R0 HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "UninstallString" + ReadRegStr $R1 HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "InstallLocation" + ${If} $R0 != "" + DetailPrint "Removing previous installation..." + StrCpy $R2 $R0 1 + ${If} $R2 == "$\"" + StrLen $R3 $R0 + IntOp $R3 $R3 - 2 + StrCpy $R0 $R0 $R3 1 + ${EndIf} + ${If} $R1 != "" + ExecWait '"$R0" /S _?=$R1' + ${Else} + ExecWait '"$R0" /S _?=$INSTDIR' + ${EndIf} + ${EndIf} +!macroend + +; Writes the standard uninstall registry entries for ${UNINSTKEY}. +!macro MintlayerUninstallRegistry + WriteRegStr HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "DisplayName" "${APPNAME}" + WriteRegStr HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "InstallLocation" "$INSTDIR" + WriteRegStr HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "DisplayVersion" "${VERSION}" + WriteRegStr HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "Publisher" "${COMPANYNAME}" + WriteRegDWORD HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "NoModify" 1 + WriteRegDWORD HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" \ + "NoRepair" 1 +!macroend diff --git a/build-tools/win/nsi/gui.nsi.in b/build-tools/win/nsi/gui.nsi.in new file mode 100644 index 0000000000..4a28ca0c14 --- /dev/null +++ b/build-tools/win/nsi/gui.nsi.in @@ -0,0 +1,86 @@ +; Mintlayer Node GUI installer. +; Rendered from gui.nsi.in by build-tools/win/create-nsis-installers.ps1 - +; do not use this file directly. Requires NSIS 3 (Unicode). +Unicode true +RequestExecutionLevel admin +SetCompressor /SOLID lzma + +!define APPNAME "Mintlayer Node GUI" +!define COMPANYNAME "Mintlayer" +!define VERSION "@VERSION@" +!define UNINSTKEY "${COMPANYNAME} ${APPNAME}" + +Name "${APPNAME}" +InstallDir "$PROGRAMFILES64\${COMPANYNAME}\${APPNAME}" +OutFile "Mintlayer_Node_GUI_win_${VERSION}_Setup.exe" + +!addincludedir "@NSI_DIR@" +!include "MUI2.nsh" +!include "LogicLib.nsh" + +!define MUI_ABORTWARNING + +; License page +!insertmacro MUI_PAGE_LICENSE "LICENSE.txt" + +; Pages +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_INSTFILES + +; Languages +!insertmacro MUI_LANGUAGE "English" + +!include "common.nsh" + +; Silently remove a previously installed version so upgrades do not leave +; stale files behind. +Function .onInit + !insertmacro MintlayerRemovePreviousInstall +FunctionEnd + +; The install is machine-wide (admin); make the shortcuts available to all +; users ($DESKTOP/$SMPROGRAMS resolve to the common profile directories). +Section "Install" + SetShellVarContext all + SetOutPath "$INSTDIR" + File "target\release\node-gui.exe" + File "LICENSE.txt" + + ; Create desktop shortcut + CreateShortcut "$DESKTOP\${APPNAME}.lnk" "$INSTDIR\node-gui.exe" + + ; Create Start Menu shortcut + CreateDirectory "$SMPROGRAMS\${COMPANYNAME}" + CreateShortcut "$SMPROGRAMS\${COMPANYNAME}\${APPNAME}.lnk" "$INSTDIR\node-gui.exe" + + ; Create the uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" +SectionEnd + +Section -PostInstall + !insertmacro MintlayerUninstallRegistry +SectionEnd + +Section "Uninstall" + SetShellVarContext all + + ; Remove files + Delete "$INSTDIR\node-gui.exe" + Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\uninstall.exe" + + ; Remove shortcuts + Delete "$DESKTOP\${APPNAME}.lnk" + Delete "$SMPROGRAMS\${COMPANYNAME}\${APPNAME}.lnk" + RMDir "$SMPROGRAMS\${COMPANYNAME}" + + ; Remove directories used + RMDir "$INSTDIR" + + ; Remove uninstall registry keys + DeleteRegKey HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" +SectionEnd diff --git a/build-tools/win/nsi/node.nsi.in b/build-tools/win/nsi/node.nsi.in new file mode 100644 index 0000000000..e95b0e0ac9 --- /dev/null +++ b/build-tools/win/nsi/node.nsi.in @@ -0,0 +1,231 @@ +; Mintlayer Node installer (CLI daemons and tools). +; Rendered from node.nsi.in by build-tools/win/create-nsis-installers.ps1 - +; do not use this file directly. Requires NSIS 3 (Unicode). +Unicode true +RequestExecutionLevel admin +SetCompressor /SOLID lzma + +!define APPNAME "Mintlayer Node" +!define COMPANYNAME "Mintlayer" +!define VERSION "@VERSION@" +!define UNINSTKEY "${COMPANYNAME} ${APPNAME}" +!define BACKUP_KEY "Software\${COMPANYNAME}\${APPNAME}" +!define SERVICE_NAME "MintlayerNode" +!define FIREWALL_RULE_NAME "Mintlayer Node" + +; Registry key holding the machine-wide PATH (expanded strings). +!define ENV_KEY "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" +; Stock NSIS builds cap script strings at 1024 characters; keep a safety +; margin so rewriting the PATH can never truncate it. +!define PATH_MAX_SAFE_LEN 900 + +Name "${APPNAME}" +InstallDir "$PROGRAMFILES64\${COMPANYNAME}\${APPNAME}" +OutFile "Mintlayer_Node_win_${VERSION}_Setup.exe" + +!addincludedir "@NSI_DIR@" +!include "MUI2.nsh" +!include "LogicLib.nsh" +!include "WinMessages.nsh" ; HWND_BROADCAST, WM_SETTINGCHANGE +!include "StrFunc.nsh" +; StrFunc declarations (must appear before any use; the `Un` variants are the +; uninstaller counterparts). +${StrLoc} +${UnStrLoc} + +!define MUI_ABORTWARNING + +; License page +!insertmacro MUI_PAGE_LICENSE "LICENSE.txt" + +; Pages +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_COMPONENTS +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_INSTFILES + +; Languages +!insertmacro MUI_LANGUAGE "English" + +!include "common.nsh" + +; Silently remove a previously installed version so upgrades behave like +; fresh installs (the old uninstaller cleans PATH/service state). +Function .onInit + !insertmacro MintlayerRemovePreviousInstall +FunctionEnd + +; --------------------------------------------------------------------------- +; Appends $INSTDIR to the machine PATH (idempotent); writes the registry +; directly and notifies running applications so new shells pick the change up. +; +; Note: ReadRegStr returns REG_EXPAND_SZ values with %-references expanded on +; the NSIS builds in use, so this rewrites e.g. %SystemRoot% references in +; expanded form. The pre-write value is backed up under BACKUP_KEY for +; recovery, and PATHs too long for NSIS string limits are left untouched. +; --------------------------------------------------------------------------- +Function AddToPath + Push $R0 + Push $R1 + Push $R2 + ReadRegStr $R0 HKLM "${ENV_KEY}" "Path" + ${If} $R0 == "" + WriteRegExpandStr HKLM "${ENV_KEY}" "Path" "$INSTDIR" + SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 + ${Else} + ; Search with ';' sentinels so that e.g. "...\Mintlayer Node" does not + ; match "...\Mintlayer Node GUI". StrLoc yields an empty string (or + ; "-1" on some NSIS versions) when the needle is not found. + ${StrLoc} $R1 ";$R0;" ";$INSTDIR;" ">" + ${If} $R1 == "" + ${OrIf} $R1 == "-1" + StrLen $R2 $R0 + ${If} $R2 > ${PATH_MAX_SAFE_LEN} + DetailPrint "Machine PATH too long ($R2 chars) to modify safely; add $INSTDIR manually." + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "The system PATH is too long ($R2 characters) for this installer to modify safely (limit ${PATH_MAX_SAFE_LEN}).$\r$\n$\r$\nPlease add the following directory to the PATH manually:$\r$\n$INSTDIR" + ${Else} + ; Back up the previous value so a botched update is recoverable + ; (the uninstaller removes the backup key on success). + WriteRegStr HKLM "${BACKUP_KEY}" "PrevPath" "$R0" + WriteRegExpandStr HKLM "${ENV_KEY}" "Path" "$R0;$INSTDIR" + SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 + ${EndIf} + ${EndIf} + ${EndIf} + Pop $R2 + Pop $R1 + Pop $R0 +FunctionEnd + +; --------------------------------------------------------------------------- +; Removes $INSTDIR from the machine PATH (no-op when absent). +; --------------------------------------------------------------------------- +Function un.RemoveFromPath + Push $R0 + Push $R1 + Push $R2 + Push $R3 + Push $R4 + ReadRegStr $R0 HKLM "${ENV_KEY}" "Path" + ${Unless} $R0 == "" + ; Work on ';'-sentinel copies: with norm = ";" + $R0 + ";" and the + ; needle ";$INSTDIR;" found at index $R1 in norm, the entry starts at + ; offset $R1 in $R0, preceded by the sentinel ';' at $R1 - 1 (when + ; present) and followed by the trailing sentinel. StrLoc yields an + ; empty string (or "-1" on some NSIS versions) when not found. + ${UnStrLoc} $R1 ";$R0;" ";$INSTDIR;" ">" + ${If} $R1 != "" + ${AndIf} $R1 != "-1" + StrLen $R2 "$INSTDIR" + ${If} $R1 > 0 + IntOp $R3 $R1 - 1 ; drop the preceding ';' + StrCpy $R4 $R0 $R3 ; part before the entry + ${Else} + StrCpy $R4 "" + ${EndIf} + IntOp $R3 $R1 + $R2 ; offset just after the entry + StrCpy $R2 $R0 "" $R3 ; part after the entry + StrCpy $R0 "$R4$R2" + ; Trim a leading or trailing ';' left over at a boundary. + StrCpy $R3 $R0 1 + ${If} $R3 == ";" + StrCpy $R0 $R0 "" 1 + ${EndIf} + StrCpy $R3 $R0 1 -1 + ${If} $R3 == ";" + StrCpy $R0 $R0 -1 + ${EndIf} + WriteRegExpandStr HKLM "${ENV_KEY}" "Path" "$R0" + SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 + ${EndIf} + ${EndUnless} + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R1 + Pop $R0 +FunctionEnd + +; --------------------------------------------------------------------------- +; Sections +; --------------------------------------------------------------------------- +Section "Mintlayer node binaries" SEC_BINARIES + SectionIn RO + SetOutPath "$INSTDIR" + File "target\release\node-daemon.exe" + File "target\release\wallet-rpc-daemon.exe" + File "target\release\api-web-server.exe" + File "target\release\api-blockchain-scanner-daemon.exe" + File "target\release\dns-server.exe" + File "target\release\wallet-cli.exe" + File "target\release\wallet-address-generator.exe" + File "LICENSE.txt" +SectionEnd + +Section "Add installation directory to the PATH" SEC_PATH + Call AddToPath +SectionEnd + +; Optional (off by default): register the mainnet node as a Windows service. +; The blockchain data directory is $INSTDIR\data and an inbound TCP firewall +; rule is added so peers can reach the node's p2p port (any local port; the +; node's default is chosen by its configuration). +Section /o "Run the mainnet node as a Windows service" SEC_SERVICE + CreateDirectory "$INSTDIR\data" + nsExec::ExecToLog 'sc.exe create ${SERVICE_NAME} start= auto DisplayName= "Mintlayer Node (mainnet)" binPath= "\"$INSTDIR\node-daemon.exe\" --datadir \"$INSTDIR\data\" mainnet"' + Pop $R9 + DetailPrint "sc.exe create exit code: $R9" + nsExec::ExecToLog 'sc.exe description ${SERVICE_NAME} "Mintlayer full node daemon (mainnet chain)"' + Pop $R9 + nsExec::ExecToLog 'netsh.exe advfirewall firewall add rule name="${FIREWALL_RULE_NAME}" dir=in action=allow protocol=TCP program="$INSTDIR\node-daemon.exe" enable=yes' + Pop $R9 + DetailPrint "netsh.exe add rule exit code: $R9" +SectionEnd + +; --------------------------------------------------------------------------- +; Uninstallation +; --------------------------------------------------------------------------- +Section "Uninstall" + ; Stop and unregister the service (harmless if it was never registered). + nsExec::ExecToLog 'sc.exe stop ${SERVICE_NAME}' + Pop $R9 + nsExec::ExecToLog 'sc.exe delete ${SERVICE_NAME}' + Pop $R9 + nsExec::ExecToLog 'netsh.exe advfirewall firewall delete rule name="${FIREWALL_RULE_NAME}"' + Pop $R9 + + ; Remove the PATH entry added by the installer. + Call un.RemoveFromPath + + Delete "$INSTDIR\node-daemon.exe" + Delete "$INSTDIR\wallet-rpc-daemon.exe" + Delete "$INSTDIR\api-web-server.exe" + Delete "$INSTDIR\api-blockchain-scanner-daemon.exe" + Delete "$INSTDIR\dns-server.exe" + Delete "$INSTDIR\wallet-cli.exe" + Delete "$INSTDIR\wallet-address-generator.exe" + Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\uninstall.exe" + + ; Note: $INSTDIR\data (blockchain storage) is intentionally preserved. + + DeleteRegKey HKLM \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTKEY}" + ; The install succeeded end-to-end; drop the PATH backup too. + DeleteRegKey HKLM "${BACKUP_KEY}" + + RMDir "$INSTDIR" +SectionEnd + +; --------------------------------------------------------------------------- +; Uninstaller registry entries +; --------------------------------------------------------------------------- +Section -PostInstall + !insertmacro MintlayerUninstallRegistry + + ; Create the uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" +SectionEnd diff --git a/build-tools/win/smoke-install.ps1 b/build-tools/win/smoke-install.ps1 new file mode 100644 index 0000000000..dc87cb5bdc --- /dev/null +++ b/build-tools/win/smoke-install.ps1 @@ -0,0 +1,154 @@ +# Install-and-uninstall smoke test for the NSIS installers produced by +# create-nsis-installers.ps1. Runs on a Windows machine (CI runners are +# administrator, so the machine-wide install works without prompts). +# +# For each installer: silent install, verify files, registry uninstall entry +# and (for the node installer) the machine PATH entry, run every installed +# binary with --help, then silently uninstall and verify everything is gone. +# The optional Windows service section is off by default and is not exercised +# here; verify it manually on a real system (see packaging instructions). + +param ( + [Parameter(Mandatory = $true)] + [string]$Installer, + + [Parameter(Mandatory = $true)] + [ValidateSet("Mintlayer Node", "Mintlayer Node GUI")] + [string]$AppName, + + [Parameter(Mandatory = $true)] + [ValidateSet("node", "gui")] + [string]$Kind, + + [Parameter(Mandatory = $true)] + [string]$Version +) + +$ErrorActionPreference = "Stop" + +# The installers write to the 64-bit Program Files and the native registry +# view; a 32-bit PowerShell would resolve $env:ProgramFiles to "Program Files +# (x86)" and read the redirected WOW6432Node view, producing false failures. +if (-not [Environment]::Is64BitProcess) { + throw "this smoke test must run under 64-bit PowerShell (the installers write to the native registry view)" +} + +$InstallDir = Join-Path $env:ProgramFiles (Join-Path "Mintlayer" $AppName) +$UninstKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Mintlayer $AppName" + +$Binaries = if ($Kind -eq "node") { + @( + "node-daemon", "wallet-rpc-daemon", "api-web-server", + "api-blockchain-scanner-daemon", "dns-server", "wallet-cli", + "wallet-address-generator" + ) +} else { + @("node-gui") +} + +function Assert-PathEntry { + param ([bool]$ShouldExist) + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $present = ($machinePath -split ";" -contains $InstallDir) + # The installer deliberately skips the PATH write when the machine PATH exceeds its NSIS + # safe-length limit (PATH_MAX_SAFE_LEN = 900 in node.nsi.in), and in a silent install the + # corresponding warning goes unnoticed; treat that case as the designed behavior instead of + # a false failure. Note: this reads the expanded value while the installer measures the raw + # one, so the threshold check is approximate by nature. + if (-not $present -and $machinePath.Length -gt 900) { + Write-Warning "machine PATH is longer than the installer's safe limit (900 chars); the PATH entry was skipped by design" + return + } + if ($ShouldExist -and -not $present) { + throw "machine PATH does not contain '$InstallDir'" + } + if (-not $ShouldExist -and $present) { + throw "machine PATH still contains '$InstallDir'" + } +} + +Write-Host "== silent install: $Installer" +$p = Start-Process -FilePath $Installer -ArgumentList "/S" -Wait -PassThru +if ($p.ExitCode -ne 0) { + throw "installer exited with $($p.ExitCode)" +} + +Write-Host "== files and uninstall registry entry" +foreach ($bin in $Binaries) { + $exe = Join-Path $InstallDir "$bin.exe" + if (-not (Test-Path $exe)) { + throw "not installed: $exe" + } +} +if (-not (Test-Path (Join-Path $InstallDir "LICENSE.txt"))) { + throw "not installed: LICENSE.txt" +} +if (-not (Test-Path (Join-Path $InstallDir "uninstall.exe"))) { + throw "not installed: uninstall.exe" +} + +$reg = Get-ItemProperty -Path $UninstKey -ErrorAction SilentlyContinue +if ($null -eq $reg) { + throw "uninstall registry key missing: $UninstKey" +} +if ($reg.DisplayVersion -ne $Version) { + throw "DisplayVersion is '$($reg.DisplayVersion)', expected '$Version'" +} +Write-Host " ok: files + registry (DisplayVersion $Version)" + +if ($Kind -eq "node") { + Write-Host "== machine PATH" + Assert-PathEntry -ShouldExist $true + Write-Host " ok: PATH contains $InstallDir" +} + +Write-Host "== binaries answer --help" +foreach ($bin in $Binaries) { + $exe = Join-Path $InstallDir "$bin.exe" + # Note: the invocation is bounded by a timeout: a binary that ignores --help and starts + # serving (as the daemons may) or blocks on input must not hang the smoke test forever. + $out = Join-Path $env:TEMP "mintlayer-smoke-$bin.out.log" + $err = Join-Path $env:TEMP "mintlayer-smoke-$bin.err.log" + $proc = Start-Process -FilePath $exe -ArgumentList "--help" -NoNewWindow -PassThru ` + -RedirectStandardOutput $out -RedirectStandardError $err + if (-not $proc.WaitForExit(60000)) { + $proc.Kill() + throw "$bin --help timed out after 60s (possible daemon start)" + } + if ($proc.ExitCode -ne 0) { + throw "$bin --help exited with $($proc.ExitCode)" + } + Write-Host " ok: $bin --help" +} + +Write-Host "== silent uninstall" +$uninstaller = Join-Path $InstallDir "uninstall.exe" +# '_?=' pins the uninstaller to the install dir (unquoted, as documented) so +# it does not copy itself to a temp location, which would make -Wait return +# before deletion finishes. +$p = Start-Process -FilePath $uninstaller -ArgumentList "/S", "_?=$InstallDir" -Wait -PassThru +if ($p.ExitCode -ne 0) { + throw "uninstaller exited with $($p.ExitCode)" +} +# The uninstaller may lag a little even with '_?='; poll briefly. +$deadline = (Get-Date).AddSeconds(30) +while ((Test-Path (Join-Path $InstallDir "uninstall.exe")) -and (Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 500 +} + +foreach ($bin in $Binaries) { + if (Test-Path (Join-Path $InstallDir "$bin.exe")) { + throw "uninstall left binaries behind in $InstallDir" + } +} +if (Get-ItemProperty -Path $UninstKey -ErrorAction SilentlyContinue) { + throw "uninstall registry key still present: $UninstKey" +} +if ($Kind -eq "node") { + Assert-PathEntry -ShouldExist $false + Write-Host " ok: binaries, registry and PATH cleaned up" +} else { + Write-Host " ok: binaries and registry cleaned up" +} + +Write-Host "smoke test passed for $AppName" diff --git a/packaging/README.md b/packaging/README.md index b6748d1237..a33a2079da 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,9 +1,9 @@ # Native Linux packages -Proper Debian and Fedora packages for the Mintlayer node software: systemd -integration, hardware-wallet udev rules, man pages, conffiles, declared -library dependencies, and lint/install validation — for both amd64/x86_64 and -arm64/aarch64. +Proper Debian, Fedora and Arch packages for the Mintlayer node software: +systemd integration, hardware-wallet udev rules, man pages, conffiles, +declared library dependencies, and lint/install validation — for both +amd64/x86_64 and arm64/aarch64. The same scripts run in CI (`.github/workflows/release_linux.yml`) and locally, inside the same container images, so a green local run means the @@ -16,6 +16,9 @@ tagged release build will behave identically. | `mintlayer-node` | node-daemon, wallet-rpc-daemon, api-web-server, api-blockchain-scanner-daemon, dns-server, wallet-cli, wallet-address-generator + systemd units, sysusers, preset, udev rules, man pages, `/etc/mintlayer` conffiles | | `mintlayer-node-gui` | node-gui + hicolor icons + desktop entry + man page | +They are produced as `.deb` (Debian), `.rpm` (Fedora) and `.pkg.tar.zst` +(Arch) packages with identical payloads. + ### What the packages set up - **systemd template units**, one instance per chain: @@ -37,8 +40,24 @@ tagged release build will behave identically. `uaccess`, shipped with `mintlayer-node` (used by wallet-cli and, when the recommended `mintlayer-node` package is installed, by the GUI). - **Dependencies**: computed from the binaries with `dpkg-shlibdeps` (deb) / - RPM soname autorequires; the deb additionally gets an `ldd` gate that fails - the build if any library is unresolved. + RPM soname autorequires / `pacman -F` lookups (Arch); the deb additionally + gets an `ldd` gate that fails the build if any library is unresolved. + +### Arch-specific notes + +- The Arch images published by the Arch project are amd64-only, so the + aarch64 package is repackaged cross-target from the amd64 container (like + the local cross-target rpm builds): binaries are shipped unstripped, man + pages are stubs and the dependency list comes from a static map (see + `packaging/arch/build.sh`). The x86_64 package is fully arch-matched. +- Arch convention is to not start services from package scripts: the install + scriptlet applies the preset policy (enabling `mintlayer-node@mainnet`) + but does not start the unit; start it with + `systemctl start mintlayer-node@mainnet.service`. +- The GUI package declares `mintlayer-node` as an `optdepends` (Arch's + equivalent of deb/rpm Recommends); install it for the udev rules. +- The packages are not published to the AUR; install a release artifact + directly with `sudo pacman -U Mintlayer_Node_linux__.pkg.tar.zst`. ## Local testing (before tagging a release) @@ -61,13 +80,17 @@ The script builds binaries with the exact CI flags (`cargo build --release --locked --features trezor,ledger`), then: 1. assembles debs inside `debian:12` (arm64 via `--platform linux/arm64`) -2. assembles rpms inside `fedora:latest` (`--target x86_64` and `aarch64`; +2. assembles rpms inside `$FEDORA_IMAGE` (fedora:44) (`--target x86_64` and `aarch64`; repackaging only, no emulation needed for rpm) -3. runs lintian / rpmlint inside the same containers -4. installs each package in a **fresh** container and verifies: system user, +3. assembles Arch packages inside `archlinux:base` (x86_64 arch-matched; + aarch64 cross-targeted from the amd64 container — Arch publishes no arm64 + images) +4. runs lintian / rpmlint / namcap inside the same containers +5. installs each package in a **fresh** container and verifies: system user, binaries run (`--help`), `systemd-analyze verify` on all units, preset, - conffiles, icons, desktop file -5. checks the artifact names against the globs `release.yml` uploads + conffiles, icons, desktop file (the Arch arm64 packages skip the + install step: pacman refuses foreign-architecture packages) +6. checks the artifact names against the globs `release.yml` uploads A final summary matrix prints; exit code 0 = safe to tag. @@ -82,20 +105,22 @@ tagging. `release_linux.yml` calls the same builders per matrix arch: - deb: `docker run --platform linux/$ARCH debian:12 packaging/deb/build.sh …` -- rpm: `docker run fedora:latest packaging/rpm/build.sh …` +- rpm: `docker run $FEDORA_IMAGE packaging/rpm/build.sh …` +- arch pkg: `docker run $ARCH_IMAGE packaging/arch/build.sh …` (amd64-only + images; see the Arch-specific notes above) - smoke: fresh-container installs via `packaging/checks/smoke-*.sh` Artifact names are unchanged (`Mintlayer_Node_linux__.deb/rpm`, -`Mintlayer_Node_GUI_linux__.deb/rpm`) so `release.yml` -attaches them exactly as before. New: GUI + node rpms are produced for both -arches (previously rpm was x86_64-only), and `workflow_dispatch` allows a -no-tag dry run. +`Mintlayer_Node_GUI_linux__.deb/rpm`, plus the +`.pkg.tar.zst` variants) so `release.yml` attaches them exactly as before. +New: GUI + node rpms are produced for both arches (previously rpm was +x86_64-only), and `workflow_dispatch` allows a no-tag dry run. ## Layout ``` packaging/ - common/ assets shared by both package formats + common/ assets shared by all package formats systemd/ template units (one per daemon) sysusers/ mintlayer system user definition udev/ Ledger/Trezor hidraw rules @@ -104,7 +129,8 @@ packaging/ applications/ .desktop entry for the GUI deb/ control template, maintscripts, changelog, build.sh rpm/ spec templates, build.sh - checks/ smoke-deb.sh, smoke-rpm.sh, verify-artifacts.sh + arch/ PKGBUILD templates, install scriptlet, build.sh + checks/ smoke-deb.sh, smoke-rpm.sh, smoke-arch.sh, verify-artifacts.sh make-icons.sh hicolor icon set generator test-local.sh full local replica of the release pipeline dist/ build output (gitignored) diff --git a/packaging/arch/PKGBUILD-gui.in b/packaging/arch/PKGBUILD-gui.in new file mode 100644 index 0000000000..401c3d2966 --- /dev/null +++ b/packaging/arch/PKGBUILD-gui.in @@ -0,0 +1,23 @@ +# Maintainer: Mintlayer +# Rendered by packaging/arch/build.sh from this template — do not use directly. +pkgname=mintlayer-node-gui +pkgver=@PKGVER@ +pkgrel=1 +pkgdesc="Graphical interface for the Mintlayer node" +arch=('@ARCH@') +url="https://github.com/mintlayer/mintlayer-core" +license=('MIT') +depends=(@DEPENDS@ 'hicolor-icon-theme') +optdepends=('mintlayer-node: Ledger/Trezor hardware wallet udev rules') +# The GUI recommends the node package for the Ledger/Trezor udev rules, like +# the deb/rpm packages do; a soft preference cannot be expressed in Arch +# metadata, so it is only documented here and in packaging/README.md. +options=('!strip' '!debug') + +# Repackaging of prebuilt binaries only (see packaging/README.md). The +# payload directory is staged by packaging/arch/build.sh and rendered in +# place of @PAYLOAD_DIR@; it stays root-owned so the package records correct +# file ownership. +package() { + cp -a '@PAYLOAD_DIR@/.' "$pkgdir/" +} diff --git a/packaging/arch/PKGBUILD-node.in b/packaging/arch/PKGBUILD-node.in new file mode 100644 index 0000000000..22f93614f8 --- /dev/null +++ b/packaging/arch/PKGBUILD-node.in @@ -0,0 +1,23 @@ +# Maintainer: Mintlayer +# Rendered by packaging/arch/build.sh from this template — do not use directly. +pkgname=mintlayer-node +pkgver=@PKGVER@ +pkgrel=1 +pkgdesc="Mintlayer node and associated tools" +arch=('@ARCH@') +url="https://github.com/mintlayer/mintlayer-core" +license=('MIT') +depends=(@DEPENDS@) +backup=(@BACKUP@) +install='mintlayer-node.install' +# The payload consists of prebuilt release binaries; makepkg only repackages +# the staged tree (strip is handled by the builder where arch-matched). +options=('!strip' '!debug') + +# Repackaging of prebuilt binaries only (see packaging/README.md). The +# payload directory is staged by packaging/arch/build.sh and rendered in +# place of @PAYLOAD_DIR@; it stays root-owned so the package records correct +# file ownership. +package() { + cp -a '@PAYLOAD_DIR@/.' "$pkgdir/" +} diff --git a/packaging/arch/build.sh b/packaging/arch/build.sh new file mode 100755 index 0000000000..3717797a98 --- /dev/null +++ b/packaging/arch/build.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Build the mintlayer-node / mintlayer-node-gui Arch packages (.pkg.tar.zst). +# +# Intended to run inside an archlinux:base container. Arch publishes amd64 +# images only, so: +# * x86_64 target: the container is arch-matched — binaries are stripped, +# dependencies are resolved from ldd via `pacman -F`, and man pages are +# generated from the real --help output. +# * aarch64 target: the same amd64 container repackages the prebuilt +# binaries cross-target (exactly like the cross-target rpm legs of +# packaging/test-local.sh): strip is skipped, man pages are stubs and the +# dependency list falls back to a static map (see FALLBACK_DEPENDS_*). +# Self-provisions its build dependencies. +# +# Usage: +# build.sh --package node --arch x86_64|aarch64 --version X.Y.Z \ +# --binaries-dir DIR --out DIR +# build.sh --package gui --arch x86_64|aarch64 --version X.Y.Z \ +# --gui-binary PATH --repo-root DIR --out DIR +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=../common/lib.sh +. "$PKG_ROOT/common/lib.sh" + +PACKAGE="" +ARCH="" +VERSION="" +BINARIES_DIR="" +GUI_BINARY="" +OUT_DIR="" +REPO_ROOT="" + +while [ $# -gt 0 ]; do + case "$1" in + --package) PACKAGE="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --binaries-dir) BINARIES_DIR="$2"; shift 2 ;; + --gui-binary) GUI_BINARY="$2"; shift 2 ;; + --repo-root) REPO_ROOT="$2"; shift 2 ;; + --out) OUT_DIR="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +[ -n "$PACKAGE" ] && [ -n "$ARCH" ] && [ -n "$VERSION" ] && [ -n "$OUT_DIR" ] || + { echo "missing required args (package/arch/version/out)" >&2; exit 2; } +[ -n "$REPO_ROOT" ] || REPO_ROOT="$PKG_ROOT/.." + +case "$ARCH" in + x86_64|aarch64) ;; + *) echo "invalid --arch: $ARCH (expected x86_64 or aarch64)" >&2; exit 2 ;; +esac + +# Sanitize version for Arch (pkgver allows only alphanumerics, dots and +# underscores). Note: pre-release versions (with a '-' suffix) are rejected: +# Arch's vercmp has no '~' equivalent, so '1.4.1_rc1' would sort NEWER than +# '1.4.1' and systems that installed the RC would refuse to upgrade to the +# final release. The deb/rpm builders keep pre-release support ('-' maps to +# '~' there, which sorts before the final release). +if ! validate_version "$VERSION"; then + echo "$VERSION_FORMAT_ERROR" >&2 + exit 2 +fi +case "$VERSION" in + *-*) + echo "pre-release versions are not supported for Arch packages: $VERSION" >&2 + exit 2 + ;; +esac +PKGVER="$VERSION" + +NATIVE=0 +[ "$ARCH" = "$(uname -m)" ] && NATIVE=1 + +# --------------------------------------------------------------------------- +# Cross-target dependency fallback (aarch64 binaries cannot be ldd'd from the +# amd64 container). Derived from the ldd output of the 1.4.x release binaries +# and verified against `pacman -F`; the GUI additions cover the libraries that +# winit/glutin load with dlopen at runtime (they are not recorded as ELF +# dependencies, but Arch minimal installs may lack them). +# --------------------------------------------------------------------------- +FALLBACK_DEPENDS_NODE=(glibc gcc-libs libdbus libusb systemd-libs) +FALLBACK_DEPENDS_GUI=(libx11 libxcursor libxrandr libxi libxkbcommon wayland libglvnd) + +# --------------------------------------------------------------------------- +# Self-provision build dependencies (no-ops when already present) +# --------------------------------------------------------------------------- +pacman -Syu --noconfirm --needed >/dev/null +# imagemagick: only needed when make-icons.sh has to run (GUI package without +# pre-generated icons); hicolor-icon-theme: runtime dependency of the GUI +# package that makepkg verifies. Self-provisioned like the other tools. +pacman -S --noconfirm --needed --asdeps base-devel sudo namcap help2man file \ + binutils imagemagick hicolor-icon-theme >/dev/null + +BUILD_USER=builduser +id "$BUILD_USER" >/dev/null 2>&1 || useradd -m "$BUILD_USER" + +WORK_DIR="$(mktemp -d /tmp/mintlayer-arch-build.XXXXXX)" +BR="$WORK_DIR/payload" +mkdir -p "$BR/usr/bin" +mkdir -p "$OUT_DIR" + +if [ "$PACKAGE" = node ]; then + [ -n "$BINARIES_DIR" ] || { echo "--binaries-dir required for node" >&2; exit 2; } + for bin in "${NODE_BINARIES[@]}"; do + install -m 0755 "$BINARIES_DIR/$bin" "$BR/usr/bin/mintlayer-$bin" + done + + mkdir -p "$BR/usr/lib/systemd/system" + for unit in "$PKG_ROOT/common/systemd/"*.service; do + install -m 0644 "$unit" "$BR/usr/lib/systemd/system/" + done + install -D -m 0644 "$PKG_ROOT/common/sysusers/mintlayer.conf" \ + "$BR/usr/lib/sysusers.d/mintlayer.conf" + install -D -m 0644 "$PKG_ROOT/common/preset/90-mintlayer.preset" \ + "$BR/usr/lib/systemd/system-preset/90-mintlayer.preset" + install -D -m 0644 "$PKG_ROOT/common/udev/51-mintlayer.rules" \ + "$BR/usr/lib/udev/rules.d/51-mintlayer.rules" + + for chain in mainnet testnet; do + for env in node.env wallet-rpc.env api-web-server.env \ + api-blockchain-scanner.env dns-server.env; do + install -D -m 0644 "$PKG_ROOT/common/env/$env" \ + "$BR/etc/mintlayer/$chain/$env" + done + done + + ARTIFACT="Mintlayer_Node_linux_${VERSION}_${ARCH}.pkg.tar.zst" + PKG_NAME="mintlayer-node" + PKGBUILD_IN="PKGBUILD-node.in" +else + [ -n "$GUI_BINARY" ] || { echo "--gui-binary required for gui" >&2; exit 2; } + install -m 0755 "$GUI_BINARY" "$BR/usr/bin/mintlayer-node-gui" + if [ -d "$OUT_DIR/assets/icons/usr" ]; then + # Pre-generated hicolor icon set (shared with the deb/rpm builders) + cp -r "$OUT_DIR/assets/icons/usr" "$BR/" + else + "$PKG_ROOT/make-icons.sh" "$REPO_ROOT/build-tools/assets/node-gui-icon_512.png" "$BR" + fi + install -D -m 0644 "$PKG_ROOT/common/applications/mintlayer-node-gui.desktop" \ + "$BR/usr/share/applications/mintlayer-node-gui.desktop" + + ARTIFACT="Mintlayer_Node_GUI_linux_${VERSION}_${ARCH}.pkg.tar.zst" + PKG_NAME="mintlayer-node-gui" + PKGBUILD_IN="PKGBUILD-gui.in" +fi + +# --------------------------------------------------------------------------- +# Strip binaries as distro packages do — but only when the container arch +# matches the target (see the header comment for the cross-target behavior). +# --------------------------------------------------------------------------- +if [ "$NATIVE" -eq 1 ]; then + # The if-guard keeps an "already stripped" match failure from tripping + # errexit (same as the deb builder). + for binpath in "$BR"/usr/bin/*; do + if file "$binpath" | grep -q "not stripped"; then + strip --strip-unneeded "$binpath" + fi + done +else + echo "cross-target build ($(uname -m) container, $ARCH target): skipping strip" +fi + +# --------------------------------------------------------------------------- +# Man pages from --help output (stubs in the cross-target case, like rpm) +# --------------------------------------------------------------------------- +gen_man "$BR" "$VERSION" "$NATIVE" + +# License file consumed by the payload (also declared via license=('MIT')) +install -D -m 0644 "$REPO_ROOT/LICENSE" "$BR/usr/share/licenses/$PKG_NAME/LICENSE" + +# --------------------------------------------------------------------------- +# Dependency list +# --------------------------------------------------------------------------- +if [ "$NATIVE" -eq 1 ]; then + # Union of the shared libraries across all binaries: keep only soname- + # shaped tokens (drops the per-binary ldd header lines, absolute + # interpreter paths and ldd's "statically linked" marker text; the + # runtime linker belongs to glibc, which every Arch system has). + LIBS="$(for binpath in "$BR"/usr/bin/*; do ldd "$binpath" 2>/dev/null; done \ + | awk '{print $1}' | grep -E '^[^/]+\.so(\.|$)' | grep -v '^linux-vdso' \ + | sort -u || true)" + MISSING=0 + DEPENDS="" + if [ -n "$LIBS" ]; then + # Download the pacman file database used for the lib -> package lookup + pacman -Fy --noconfirm >/dev/null + for lib in $LIBS; do + # --machinereadable separates fields with NUL bytes; translate + # them so awk can pick the package name (field 2). A soname can be + # provided by several packages, and the row order depends on the + # repo/database layout, so all providers are collected and the + # choice is made deterministically: the shortest package name + # (preferring plain runtime packages over longer, more specific + # variants), ties broken alphabetically. + providers="$(pacman -F --machinereadable "usr/lib/$lib" 2>/dev/null \ + | tr '\0' '\t' | awk -F'\t' '{print $2}' | sort -u)" + if [ -z "$providers" ]; then + echo "ERROR: no Arch package provides usr/lib/$lib" >&2 + MISSING=1 + continue + fi + pkg="$(printf '%s\n' "$providers" \ + | awk '{ print length($0), $0 }' | sort -n -k1,1 -k2,2 | head -n1 | cut -d' ' -f2-)" + if [ "$(printf '%s\n' "$providers" | wc -l)" -gt 1 ]; then + echo "note: usr/lib/$lib has multiple providers ($(printf '%s' "$providers" | tr '\n' ' ')); using $pkg" >&2 + fi + DEPENDS="$DEPENDS $pkg" + done + fi + [ "$MISSING" -eq 0 ] || { echo "unresolved shared library dependencies" >&2; exit 1; } + DEPENDS="$(printf '%s\n' $DEPENDS | sort -u | tr '\n' ' ')" + echo "resolved depends:$DEPENDS" +else + DEPENDS="${FALLBACK_DEPENDS_NODE[*]}" + if [ "$PACKAGE" = gui ]; then + DEPENDS="$DEPENDS ${FALLBACK_DEPENDS_GUI[*]}" + fi + echo "cross-target build: using fallback depends:$DEPENDS" +fi + +# makepkg verifies that the declared runtime dependencies resolve and are +# installed, so provision them here (package names are x86_64 repo names, +# which is what the amd64 container's repos provide regardless of the target +# arch being packaged). +[ -n "$DEPENDS" ] || { echo "empty depends list" >&2; exit 1; } +pacman -S --noconfirm --needed --asdeps $DEPENDS >/dev/null + +# --------------------------------------------------------------------------- +# Render the PKGBUILD (payload tree is staged next to it; makepkg only +# repackages it). The payload stays root-owned so the packaged files record +# correct root ownership through fakeroot. +# --------------------------------------------------------------------------- +BACKUP="" +if [ "$PACKAGE" = node ]; then + # Derive the backup list from the staged tree (makepkg requires backup + # entries without a leading slash). + while IFS= read -r f; do BACKUP="$BACKUP '$f'"; done < <(cd "$BR" && find etc -type f | sort) +fi + +sed -e "s|@PKGVER@|$PKGVER|g" \ + -e "s|@ARCH@|$ARCH|g" \ + -e "s|@DEPENDS@|$DEPENDS|g" \ + -e "s|@BACKUP@|$BACKUP|g" \ + -e "s|@PAYLOAD_DIR@|$BR|g" \ + "$PKG_ROOT/arch/$PKGBUILD_IN" > "$WORK_DIR/PKGBUILD" + +if [ "$PACKAGE" = node ]; then + install -m 0644 "$PKG_ROOT/arch/mintlayer-node.install" "$WORK_DIR/mintlayer-node.install" +fi + +# The work directory itself becomes builduser-writable (makepkg creates src/ +# and pkg/ in it), but the staged payload stays root-owned: package() copies +# it with cp -a, so the packaged files record correct root ownership through +# fakeroot. The payload only needs to be world-readable, which it is. +chown "$BUILD_USER:$BUILD_USER" "$WORK_DIR" + +# --------------------------------------------------------------------------- +# Build. makepkg refuses to run as root, hence the dedicated build user. +# CARCH is pinned to the target arch so that the package name and .PKGINFO +# record the target (for the cross-target aarch64 leg the amd64 host would +# otherwise stamp its own arch, or --ignorearch would produce packages that +# pacman refuses on the real target). +# --------------------------------------------------------------------------- +sudo -H -u "$BUILD_USER" bash -ec "cd '$WORK_DIR' && CARCH='$ARCH' makepkg -f --noconfirm" + +BUILT_PKG="$WORK_DIR/${PKG_NAME}-${PKGVER}-1-${ARCH}.pkg.tar.zst" +[ -f "$BUILT_PKG" ] || { + echo "expected $BUILT_PKG was not produced" >&2 + ls -la "$WORK_DIR" >&2 + exit 1 +} +mv "$BUILT_PKG" "$OUT_DIR/$ARTIFACT" + +echo "built $OUT_DIR/$ARTIFACT" + +# --------------------------------------------------------------------------- +# Lint. namcap always exits 0 (even with errors and even on unreadable +# input), so gate on its output instead of the exit code: any " E: " line is +# an error and fails the build (warnings are allowed). +# --------------------------------------------------------------------------- +namcap_out="$(namcap "$OUT_DIR/$ARTIFACT" 2>&1)" +echo "$namcap_out" +if printf '%s\n' "$namcap_out" | grep -q ' E: '; then + echo "namcap found errors in $ARTIFACT" >&2 + exit 1 +fi +echo "namcap passed for $ARTIFACT (warnings allowed)" + +rm -rf "$WORK_DIR" diff --git a/packaging/arch/mintlayer-node.install b/packaging/arch/mintlayer-node.install new file mode 100644 index 0000000000..49f6e0ecfb --- /dev/null +++ b/packaging/arch/mintlayer-node.install @@ -0,0 +1,25 @@ +# Arch package scriptlet for mintlayer-node. +# +# The `mintlayer` system user is created automatically by pacman's systemd +# hook from the shipped sysusers.d file, and the systemd units are reloaded +# by the same hook mechanism; udev rule reloading is likewise handled by the +# distro hooks, so none of that is duplicated here. + +post_install() { + # Apply the shipped preset policy: enable the mainnet node instance. + # Note: Arch convention is to not start services from package scripts, so + # the unit is enabled for the next boot only; start it with + # systemctl start mintlayer-node@mainnet.service + systemctl preset mintlayer-node@mainnet.service >/dev/null 2>&1 || true +} + +post_upgrade() { + # Deliberately empty: preset is applied at install time only, so an admin + # who disabled the unit is not overridden on upgrade. Unit reloading is + # handled by pacman's systemd hook. + : +} + +pre_remove() { + systemctl disable --now mintlayer-node@mainnet.service >/dev/null 2>&1 || true +} diff --git a/packaging/checks/smoke-arch.sh b/packaging/checks/smoke-arch.sh new file mode 100755 index 0000000000..665a8be582 --- /dev/null +++ b/packaging/checks/smoke-arch.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Install-smoke test for a built .pkg.tar.zst. Runs INSIDE an archlinux:base +# container with the repository mounted at /work. +# Usage: smoke-arch.sh node|gui [pkg-arch] +# +# The optional fourth argument is the architecture the package was built for +# (defaults to the container architecture). For a foreign-architecture package +# (the cross-target aarch64 leg reuses the amd64-only Arch image), pacman's +# architecture check is bypassed via IgnoreArch and the binaries are executed +# through the host's qemu binfmt handlers — the same emulation the deb/rpm +# legs use for their arm64 smoke tests. +set -euo pipefail + +# Shared binary list (NODE_BINARIES) +. "$(cd "$(dirname "${BASH_SOURCE[0]}")/../common" && pwd)/lib.sh" + +PKG_FILE="$(readlink -f "$1")" +PKG_NAME="$2" +KIND="$3" +PKG_ARCH="${4:-$(uname -m)}" + +if [ "$PKG_ARCH" != "$(uname -m)" ]; then + echo "foreign-architecture package ($PKG_ARCH on $(uname -m)): enabling IgnoreArch" + sed -i "/^\[options\]$/a IgnoreArch = $PKG_ARCH" /etc/pacman.conf +fi + +# Sync the databases so the package dependencies resolve from the repos. +pacman -Sy --noconfirm >/dev/null +pacman -U --noconfirm "$PKG_FILE" >/dev/null + +echo "== pacman query ==" +pacman -Qi "$PKG_NAME" + +if [ "$KIND" = node ]; then + echo "== system user ==" + id mintlayer + + echo "== binaries ==" + for bin in "${NODE_BINARIES[@]}"; do + test -x "/usr/bin/mintlayer-$bin" + "/usr/bin/mintlayer-$bin" --help >/dev/null 2>&1 + echo " ok: mintlayer-$bin --help" + done + + echo "== systemd units ==" + shopt -s nullglob + units=(/usr/lib/systemd/system/mintlayer-*.service) + shopt -u nullglob + [ ${#units[@]} -gt 0 ] || { echo "ERROR: no mintlayer units installed" >&2; exit 1; } + for unit in "${units[@]}"; do + systemd-analyze verify "$unit" + echo " ok: $(basename "$unit")" + done + test -f /usr/lib/systemd/system-preset/90-mintlayer.preset + grep -q "enable mintlayer-node@mainnet.service" \ + /usr/lib/systemd/system-preset/90-mintlayer.preset + test -f /usr/lib/sysusers.d/mintlayer.conf + test -f /usr/lib/udev/rules.d/51-mintlayer.rules + test -f /etc/mintlayer/mainnet/node.env + test -f /etc/mintlayer/testnet/node.env + echo " ok: preset/sysusers/udev/conffiles present" + + echo "== man pages ==" + pacman -Ql "$PKG_NAME" | grep -q "usr/share/man/man1/mintlayer-.*\.1\.gz$" + echo " ok" +else + echo "== gui files ==" + test -x /usr/bin/mintlayer-node-gui + /usr/bin/mintlayer-node-gui --help >/dev/null 2>&1 + echo " ok: mintlayer-node-gui --help" + + pacman -S --noconfirm --needed desktop-file-utils >/dev/null + desktop-file-validate /usr/share/applications/mintlayer-node-gui.desktop + + echo "== icons ==" + for size in 64x64 128x128 256x256 512x512; do + test -f "/usr/share/icons/hicolor/${size}/apps/mintlayer-node-gui.png" + echo " ok: hicolor ${size}" + done + + echo "== man pages ==" + pacman -Ql "$PKG_NAME" | grep -q "usr/share/man/man1/mintlayer-node-gui\.1\.gz$" + echo " ok" +fi + +echo "smoke test passed for $PKG_NAME" diff --git a/packaging/checks/smoke-deb.sh b/packaging/checks/smoke-deb.sh index 90e192bd37..858292952b 100755 --- a/packaging/checks/smoke-deb.sh +++ b/packaging/checks/smoke-deb.sh @@ -4,6 +4,9 @@ # Usage: smoke-deb.sh node|gui set -euo pipefail +# Shared binary list (NODE_BINARIES) +. "$(cd "$(dirname "${BASH_SOURCE[0]}")/../common" && pwd)/lib.sh" + DEB_FILE="$(readlink -f "$1")" # apt requires an unambiguous path PKG_NAME="$2" KIND="$3" @@ -22,9 +25,7 @@ if [ "$KIND" = node ]; then id mintlayer echo "== binaries ==" - for bin in node-daemon wallet-rpc-daemon api-web-server \ - api-blockchain-scanner-daemon dns-server wallet-cli \ - wallet-address-generator; do + for bin in "${NODE_BINARIES[@]}"; do test -x "/usr/bin/mintlayer-$bin" "/usr/bin/mintlayer-$bin" --help >/dev/null 2>&1 echo " ok: mintlayer-$bin --help" diff --git a/packaging/checks/smoke-rpm.sh b/packaging/checks/smoke-rpm.sh index 62c93093d8..1a4c79d350 100755 --- a/packaging/checks/smoke-rpm.sh +++ b/packaging/checks/smoke-rpm.sh @@ -1,9 +1,12 @@ #!/usr/bin/env bash -# Install-smoke test for a built .rpm. Runs INSIDE a fedora:latest container +# Install-smoke test for a built .rpm. Runs INSIDE the pinned $FEDORA_IMAGE container # with the repository mounted at /work. # Usage: smoke-rpm.sh node|gui set -euo pipefail +# Shared binary list (NODE_BINARIES) +. "$(cd "$(dirname "${BASH_SOURCE[0]}")/../common" && pwd)/lib.sh" + RPM_FILE="$(readlink -f "$1")" PKG_NAME="$2" KIND="$3" @@ -20,9 +23,7 @@ if [ "$KIND" = node ]; then id mintlayer echo "== binaries ==" - for bin in node-daemon wallet-rpc-daemon api-web-server \ - api-blockchain-scanner-daemon dns-server wallet-cli \ - wallet-address-generator; do + for bin in "${NODE_BINARIES[@]}"; do test -x "/usr/bin/mintlayer-$bin" "/usr/bin/mintlayer-$bin" --help >/dev/null 2>&1 echo " ok: mintlayer-$bin --help" diff --git a/packaging/checks/verify-artifacts.sh b/packaging/checks/verify-artifacts.sh index 1df1f771b7..6aed168040 100755 --- a/packaging/checks/verify-artifacts.sh +++ b/packaging/checks/verify-artifacts.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Verify that the produced artifacts match the exact names the release # workflow globs (release.yml: artifacts: "Mintlayer*/*"). -# Usage: verify-artifacts.sh -# arch-pairs: list of "debarch:rpmarch" pairs, e.g. amd64:x86_64 arm64:aarch64 +# Usage: verify-artifacts.sh +# arch-triples: list of "debarch:rpmarch:pkgarch" triples, +# e.g. amd64:x86_64:x86_64 arm64:aarch64:aarch64 set -euo pipefail DIST_DIR="$1" @@ -10,18 +11,19 @@ VERSION="$2" shift 2 MISSING=0 -for pair in "$@"; do - if [[ "$pair" != *:* || "$pair" == *:*:* ]]; then - echo "invalid arch pair: '$pair' (expected debarch:rpmarch, e.g. amd64:x86_64)" >&2 +for triple in "$@"; do + IFS=':' read -r DEBARCH RPMARCH PKGARCH <<< "$triple" + if ! [[ "$triple" =~ ^[^:]+:[^:]+:[^:]+$ ]]; then + echo "invalid arch triple: '$triple' (expected debarch:rpmarch:pkgarch, e.g. amd64:x86_64:x86_64)" >&2 exit 2 fi - DEBARCH="${pair%%:*}" - RPMARCH="${pair##*:}" for f in "Mintlayer_Node_linux_${VERSION}_${DEBARCH}.deb" \ "Mintlayer_Node_GUI_linux_${VERSION}_${DEBARCH}.deb" \ "Mintlayer_Node_linux_${VERSION}_${RPMARCH}.rpm" \ - "Mintlayer_Node_GUI_linux_${VERSION}_${RPMARCH}.rpm"; do + "Mintlayer_Node_GUI_linux_${VERSION}_${RPMARCH}.rpm" \ + "Mintlayer_Node_linux_${VERSION}_${PKGARCH}.pkg.tar.zst" \ + "Mintlayer_Node_GUI_linux_${VERSION}_${PKGARCH}.pkg.tar.zst"; do if [ -f "$DIST_DIR/$f" ]; then echo "ok: $f" else diff --git a/packaging/common/lib.sh b/packaging/common/lib.sh new file mode 100644 index 0000000000..7a48850515 --- /dev/null +++ b/packaging/common/lib.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Shared helpers for the packaging builders (deb, rpm, arch) and smoke tests. +# Sourced, not executed. Pure bash, no external tools required except where a +# function documents them; all container images used by the builders provide +# what the functions need (the builders self-provision help2man and gzip). + +# The CLI daemons and tools shipped by the mintlayer-node package, in every +# package format. The GUI binary (node-gui) is handled separately by each +# builder. Note: the Windows installers and the workflow binary_list defaults +# duplicate this list outside the shell world (NSIS/PowerShell/YAML cannot +# source bash) — keep them in sync when a binary is added. +NODE_BINARIES=( + node-daemon + wallet-rpc-daemon + api-web-server + api-blockchain-scanner-daemon + dns-server + wallet-cli + wallet-address-generator +) + +# Validate a release version string (X.Y.Z with an optional -suffix). +# Sets VERSION_FORMAT_ERROR to a human-readable message and returns 1 when +# invalid. +# Note: the character class uses a POSIX class — explicit ranges like `+-a` +# in a glob bracket expression are parsed counter-intuitively and reject +# letters. +# Note: '~' and '+' are rejected on purpose: the rpm builder maps '-' to '~' +# and the Arch builder maps '-' to '_', and both mappings must stay injective +# so that distinct versions cannot produce the same package version. +validate_version() { + local version="$1" + if [ -z "$version" ] || [[ "$version" == *[![:alnum:].-]* ]]; then + VERSION_FORMAT_ERROR="invalid version: $version" + return 1 + fi + case "$version" in + [0-9]*.[0-9]*.[0-9]*) ;; # e.g. 1.4.1, 1.4.1-rc1 + *) + VERSION_FORMAT_ERROR="invalid version (expected X.Y.Z[-suffix]): $version" + return 1 + ;; + esac +} + +# Generate gzip-compressed man pages (from --help output) for every binary in +# into . Requires help2man and gzip (installed by +# every builder's self-provisioning step). +# gen_man +# runnable=0 ships stub pages (cross-target builds cannot execute the +# binaries). +gen_man() { + local bin_dir="$1" + local version="$2" + local runnable="$3" + + export LC_ALL=C.UTF-8 + local man_dir="$bin_dir/usr/share/man/man1" + mkdir -p "$man_dir" + local binpath binname + # Note: without nullglob, an empty or missing bin dir would make the glob expand to the + # literal path, producing a garbage '*.1.gz' man page instead of failing loudly here. + shopt -s nullglob + for binpath in "$bin_dir"/usr/bin/*; do + binname="$(basename "$binpath")" + if [ "$runnable" -eq 1 ] && "$binpath" --help >/dev/null 2>&1; then + help2man --no-info --version-string="$version" \ + --name="Part of the Mintlayer node software" \ + "$binpath" > "$man_dir/$binname.1" 2>/dev/null || + { echo "warning: help2man failed for $binname, shipping stub" >&2 + printf '.TH %s 1\n.SH NAME\n%s \\- Mintlayer tool\n' "$binname" "$binname" \ + > "$man_dir/$binname.1"; } + else + echo "warning: $binname --help not runnable, shipping stub man page" >&2 + printf '.TH %s 1\n.SH NAME\n%s \\- Mintlayer tool\n' "$binname" "$binname" \ + > "$man_dir/$binname.1" + fi + gzip -n -9 "$man_dir/$binname.1" + done + shopt -u nullglob +} diff --git a/packaging/deb/build.sh b/packaging/deb/build.sh index 6fd49a1685..2da5fa884f 100755 --- a/packaging/deb/build.sh +++ b/packaging/deb/build.sh @@ -14,6 +14,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PKG_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=../common/lib.sh +. "$PKG_ROOT/common/lib.sh" PACKAGE="" VERSION="" @@ -77,9 +79,7 @@ mkdir -p "$PKGDIR/DEBIAN" "$PKGDIR/usr/bin" if [ "$PACKAGE" = node ]; then [ -n "$BINARIES_DIR" ] || { echo "--binaries-dir required for node" >&2; exit 2; } # All daemons + CLI tools, renamed with the mintlayer- prefix - for bin in node-daemon wallet-rpc-daemon api-web-server \ - api-blockchain-scanner-daemon dns-server wallet-cli \ - wallet-address-generator; do + for bin in "${NODE_BINARIES[@]}"; do cp "$BINARIES_DIR/$bin" "$PKGDIR/usr/bin/mintlayer-$bin" chmod 0755 "$PKGDIR/usr/bin/mintlayer-$bin" done @@ -132,13 +132,10 @@ fi # Version handling # --------------------------------------------------------------------------- # Guard against sed metacharacters and nonsense versions from the tag input. -case "$VERSION" in - ''|*[!0-9.+-a-zA-Z~]*) echo "invalid --version: $VERSION" >&2; exit 2 ;; -esac -case "$VERSION" in - [0-9]*.[0-9]*.[0-9]*) ;; # e.g. 1.4.0, 1.4.0-rc1, 1.4.0-rc1-1 - *) echo "invalid --version (expected X.Y.Z[-suffix]): $VERSION" >&2; exit 2 ;; -esac +if ! validate_version "$VERSION"; then + echo "$VERSION_FORMAT_ERROR" >&2 + exit 2 +fi # The deb version always carries an explicit Debian revision (e.g. 1.4.0-1, # 1.4.0-rc1-1): without it the package looks "native", which changes lintian's @@ -162,25 +159,7 @@ gzip -n -9 -c "$CHANGELOG" > "$DOC_DIR/changelog.Debian.gz" rm -f "$CHANGELOG" # Man pages from --help output (binaries are executable on this host arch) -export LC_ALL=C.UTF-8 -MAN_DIR="$PKGDIR/usr/share/man/man1" -mkdir -p "$MAN_DIR" -for binpath in "$PKGDIR"/usr/bin/*; do - binname="$(basename "$binpath")" - if "$binpath" --help >/dev/null 2>&1; then - help2man --no-info --version-string="$VERSION" \ - --name="Part of the Mintlayer node software" \ - "$binpath" > "$MAN_DIR/$binname.1" 2>/dev/null || - { echo "warning: help2man failed for $binname, shipping stub" >&2 - printf '.TH %s 1\n.SH NAME\n%s \\- Mintlayer tool\n' "$binname" "$binname" \ - > "$MAN_DIR/$binname.1"; } - else - echo "warning: $binname --help not runnable, shipping stub man page" >&2 - printf '.TH %s 1\n.SH NAME\n%s \\- Mintlayer tool\n' "$binname" "$binname" \ - > "$MAN_DIR/$binname.1" - fi - gzip -n -9 "$MAN_DIR/$binname.1" -done +gen_man "$PKGDIR" "$VERSION" 1 # --------------------------------------------------------------------------- # Verify every binary's shared libraries resolve in this environment. diff --git a/packaging/images.env b/packaging/images.env new file mode 100644 index 0000000000..6901aab561 --- /dev/null +++ b/packaging/images.env @@ -0,0 +1,13 @@ +# Container image pins for the packaging pipeline. +# Single source of truth for both CI (.github/workflows/release_linux.yml) +# and local testing (packaging/test-local.sh) — keep pins in one place here. +# Bump deliberately: a new pin should be tested with a full test-local.sh run. + +# Fedora (rpm builder); pinned for reproducible packaging. +FEDORA_IMAGE=fedora:44 + +# Arch (pkg builder); pinned to a dated snapshot of the rolling images. +# Note: amd64 only — the aarch64 leg repackages cross-target (see +# packaging/arch/build.sh). The pin covers the base image only: build.sh +# runs `pacman -Syu`, so package versions drift until the pin is bumped. +ARCH_IMAGE=archlinux:base-20260913.0.592969 diff --git a/packaging/rpm/build.sh b/packaging/rpm/build.sh index e4fc119504..bbe811dcef 100755 --- a/packaging/rpm/build.sh +++ b/packaging/rpm/build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Build the mintlayer-node / mintlayer-node-gui RPM packages. # -# Intended to run inside a fedora:latest container (any host arch for x86_64; +# Intended to run inside the pinned $FEDORA_IMAGE container (any host arch for x86_64; # for the aarch64 target no emulation is needed: rpmbuild only repackages the # prebuilt binaries). Self-provisions its build dependencies. # @@ -14,6 +14,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PKG_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=../common/lib.sh +. "$PKG_ROOT/common/lib.sh" PACKAGE="" RPMARCH="" @@ -49,13 +51,12 @@ esac # Sanitize version for RPM (no dashes; 1.4.0-rc1 -> 1.4.0~rc1). # NOTE: do not use ${var//-/~} here — bash 5.3+ tilde-expands the replacement # word, turning '~' into $HOME. -case "$VERSION" in - ''|*[!0-9.+-a-zA-Z~]*) echo "invalid --version: $VERSION" >&2; exit 2 ;; -esac -case "$VERSION" in - [0-9]*.[0-9]*.[0-9]*) ;; # e.g. 1.4.0, 1.4.0-rc1 - *) echo "invalid --version (expected X.Y.Z[-suffix]): $VERSION" >&2; exit 2 ;; -esac +# NOTE: the mapping is injective because validate_version rejects '~' (see +# common/lib.sh). +if ! validate_version "$VERSION"; then + echo "$VERSION_FORMAT_ERROR" >&2 + exit 2 +fi RPM_VERSION="$(printf '%s' "$VERSION" | tr '-' '~')" # --------------------------------------------------------------------------- @@ -78,9 +79,7 @@ mkdir -p "$BR/usr/bin" if [ "$PACKAGE" = node ]; then [ -n "$BINARIES_DIR" ] || { echo "--binaries-dir required for node" >&2; exit 2; } - for bin in node-daemon wallet-rpc-daemon api-web-server \ - api-blockchain-scanner-daemon dns-server wallet-cli \ - wallet-address-generator; do + for bin in "${NODE_BINARIES[@]}"; do install -m 0755 "$BINARIES_DIR/$bin" "$BR/usr/bin/mintlayer-$bin" done @@ -130,35 +129,24 @@ SPEC_NAME="${SPEC_IN%.spec.in}.spec" # --------------------------------------------------------------------------- HOST_ARCH="$(uname -m)" if [ "$RPMARCH" = "$HOST_ARCH" ]; then + # The if-guard keeps an "already stripped" match failure from tripping + # errexit (same as the deb builder). for binpath in "$BR"/usr/bin/*; do - file "$binpath" | grep -q "not stripped" && strip --strip-unneeded "$binpath" + if file "$binpath" | grep -q "not stripped"; then + strip --strip-unneeded "$binpath" + fi done else echo "cross-target build ($HOST_ARCH container, $RPMARCH target): skipping strip" fi # --------------------------------------------------------------------------- -# Man pages from --help output +# Man pages from --help output (stubs on cross-target builds: the foreign-arch +# binaries cannot be executed) # --------------------------------------------------------------------------- -export LC_ALL=C.UTF-8 -MAN_DIR="$BR/usr/share/man/man1" -mkdir -p "$MAN_DIR" -for binpath in "$BR"/usr/bin/*; do - binname="$(basename "$binpath")" - if "$binpath" --help >/dev/null 2>&1; then - help2man --no-info --version-string="$VERSION" \ - --name="Part of the Mintlayer node software" \ - "$binpath" > "$MAN_DIR/$binname.1" 2>/dev/null || - { echo "warning: help2man failed for $binname, shipping stub" >&2 - printf '.TH %s 1\n.SH NAME\n%s \\- Mintlayer tool\n' "$binname" "$binname" \ - > "$MAN_DIR/$binname.1"; } - else - echo "warning: $binname --help not runnable, shipping stub man page" >&2 - printf '.TH %s 1\n.SH NAME\n%s \\- Mintlayer tool\n' "$binname" "$binname" \ - > "$MAN_DIR/$binname.1" - fi - gzip -n -9 "$MAN_DIR/$binname.1" -done +RUNNABLE=0 +[ "$RPMARCH" = "$HOST_ARCH" ] && RUNNABLE=1 +gen_man "$BR" "$VERSION" "$RUNNABLE" # License file consumed by the %files %license entry if [ "$PACKAGE" = node ]; then diff --git a/packaging/test-local.sh b/packaging/test-local.sh index 1abeda286a..b81651d040 100755 --- a/packaging/test-local.sh +++ b/packaging/test-local.sh @@ -25,6 +25,13 @@ PKG_ROOT="$REPO_ROOT/packaging" DIST="$PKG_ROOT/dist" mkdir -p "$DIST" +# Container image pins, shared with .github/workflows/release_linux.yml. +. "$PKG_ROOT/images.env" +ARCH_IMAGE="${ARCH_IMAGE:-archlinux:base-20260913.0.592969}" + +# Shared binary list (NODE_BINARIES) +. "$PKG_ROOT/common/lib.sh" + QUICK=0 SKIP_BUILD=0 SKIP_SMOKE=0 @@ -70,7 +77,8 @@ fi echo "pulling container images..." docker pull -q debian:12 >/dev/null -docker pull -q fedora:latest >/dev/null +docker pull -q "$FEDORA_IMAGE" >/dev/null +docker pull -q "$ARCH_IMAGE" >/dev/null # --------------------------------------------------------------------------- # Build (or locate) release binaries @@ -91,37 +99,7 @@ if [ "$SKIP_BUILD" -eq 1 ]; then [ -n "${pair%%=*}" ] && BIN_DIR[${pair%%=*}]="${pair##*=}" done fi -RESULTS=() -FAILED=0 -run_step() { # run_step