Skip to content

Bump version to 0.7.0-rc.32 #689

Bump version to 0.7.0-rc.32

Bump version to 0.7.0-rc.32 #689

Workflow file for this run

name: Release
on:
push:
tags:
- "v*"
- "!v*-nightly.*"
schedule:
- cron: "0 9 * * *"
workflow_dispatch:
inputs:
channel:
description: "Release channel"
required: false
default: stable
type: choice
options:
- stable
- nightly
version:
description: "Release version (without leading v). Leave empty to use the tag."
required: false
default: ""
permissions:
id-token: write
contents: write
jobs:
check_changes:
name: Check for changes since last nightly
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- id: check
name: Compare HEAD to last nightly tag
shell: bash
run: |
set -euo pipefail
last_nightly_tag=$(git tag --list 'v*-nightly.*' --sort=-creatordate | head -n 1)
if [[ -z "$last_nightly_tag" ]]; then
echo "No previous nightly tag found. Proceeding with release."
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}")
head_sha=$(git rev-parse HEAD)
if [[ "$last_nightly_sha" == "$head_sha" ]]; then
echo "No changes since last nightly release ($last_nightly_tag). Skipping."
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "Changes detected since $last_nightly_tag. Proceeding."
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
preflight:
name: Resolve release metadata
needs: [check_changes]
if: |
!failure() && !cancelled() &&
(github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
runs-on: ubuntu-latest
outputs:
release_channel: ${{ steps.meta.outputs.release_channel }}
version: ${{ steps.meta.outputs.version }}
tag: ${{ steps.meta.outputs.tag }}
release_name: ${{ steps.meta.outputs.name }}
is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
make_latest: ${{ steps.meta.outputs.make_latest }}
update_manifest: ${{ steps.meta.outputs.update_manifest }}
update_mac_manifest: ${{ steps.meta.outputs.update_mac_manifest }}
update_linux_manifest: ${{ steps.meta.outputs.update_linux_manifest }}
ref: ${{ github.sha }}
steps:
- uses: actions/checkout@v7
- id: meta
name: Resolve release metadata
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${{ github.event.inputs.channel }}" == "nightly" ) ]]; then
nightly_date="$(date -u -d "${{ github.run_started_at }}" +%Y%m%d)"
node scripts/resolve-nightly-release.mjs \
--date "$nightly_date" \
--run-number "${{ github.run_number }}" \
--sha "${{ github.sha }}" \
--github-output
echo "release_channel=nightly" >> "$GITHUB_OUTPUT"
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "make_latest=false" >> "$GITHUB_OUTPUT"
echo "update_manifest=nightly.yml" >> "$GITHUB_OUTPUT"
echo "update_mac_manifest=nightly-mac.yml" >> "$GITHUB_OUTPUT"
echo "update_linux_manifest=nightly-linux.yml" >> "$GITHUB_OUTPUT"
else
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -z "${{ github.event.inputs.version }}" ]]; then
echo "workflow_dispatch stable releases require the version input." >&2
exit 1
fi
raw="${{ github.event.inputs.version != '' && github.event.inputs.version || github.ref_name }}"
version="${raw#v}"
echo "release_channel=stable" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=v$version" >> "$GITHUB_OUTPUT"
echo "name=Nodetool v$version" >> "$GITHUB_OUTPUT"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
# Plain X.Y.Z and release candidates (X.Y.Z-rc.N) are shipped as
# the public release: non-prerelease and flagged "Latest" on GitHub.
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "make_latest=true" >> "$GITHUB_OUTPUT"
else
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "make_latest=false" >> "$GITHUB_OUTPUT"
fi
echo "update_manifest=latest.yml" >> "$GITHUB_OUTPUT"
echo "update_mac_manifest=latest-mac.yml" >> "$GITHUB_OUTPUT"
echo "update_linux_manifest=latest-linux.yml" >> "$GITHUB_OUTPUT"
fi
release-build:
needs: [preflight]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
strategy:
fail-fast: false
matrix:
os: [windows-2022, ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
env:
DEBUG: electron-builder
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
VERSION: ${{ needs.preflight.outputs.version }}
RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }}
RELEASE_TAG: ${{ needs.preflight.outputs.tag }}
RELEASE_NAME: ${{ needs.preflight.outputs.release_name }}
RELEASE_PRERELEASE: ${{ needs.preflight.outputs.is_prerelease }}
RELEASE_MAKE_LATEST: ${{ needs.preflight.outputs.make_latest }}
UPDATE_MANIFEST: ${{ needs.preflight.outputs.update_manifest }}
UPDATE_MAC_MANIFEST: ${{ needs.preflight.outputs.update_mac_manifest }}
UPDATE_LINUX_MANIFEST: ${{ needs.preflight.outputs.update_linux_manifest }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.preflight.outputs.ref }}
- name: Print selected version
run: echo "Using VERSION=$VERSION channel=$RELEASE_CHANNEL tag=$RELEASE_TAG"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Sync package versions to release tag
shell: bash
run: |
set -euo pipefail
VERSION_NO_V="${VERSION#v}"
VERSION_NO_V="${VERSION_NO_V//\//-}"
export VERSION_NO_V
echo "Syncing package.json versions to ${VERSION_NO_V}"
node -e '
const fs = require("fs");
const version = process.env.VERSION_NO_V;
const files = ["package.json", "electron/package.json", "web/package.json"];
for (const p of files) {
const pkg = JSON.parse(fs.readFileSync(p, "utf8"));
pkg.version = version;
fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + "\n");
console.log(`Updated ${p} to ${version}`);
}
'
- name: Configure Electron update channel
shell: bash
run: node scripts/configure-electron-update-channel.mjs "$VERSION"
- name: Install Apple Certificate
if: matrix.os == 'macos-latest'
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE }}
P12_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PWD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output $CERTIFICATE_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
xcrun notarytool store-credentials "AC_PASSWORD" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD"
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install system libraries for keytar (Linux)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev
- name: Configure node-gyp Python path
shell: bash
run: |
echo "npm_config_python=$(which python)" >> $GITHUB_ENV
echo "NODE_GYP_FORCE_PYTHON=$(which python)" >> $GITHUB_ENV
if [ "$RUNNER_OS" == "Windows" ]; then
PYTHON_PATH=$(which python)
echo "npm_config_python=$PYTHON_PATH" >> $GITHUB_ENV
echo "NODE_GYP_FORCE_PYTHON=$PYTHON_PATH" >> $GITHUB_ENV
fi
echo "Using Python: $(which python)"
python --version
- name: Install dependencies
shell: bash -l {0}
run: npm ci
- name: Build workspace packages
shell: bash -l {0}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
run: npm run build:packages
- name: Build Web
shell: bash -l {0}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
run: npm run build --workspace=web
- name: Create Web Build Archive
if: matrix.os == 'ubuntu-latest'
shell: bash
id: create_archive
run: |
VERSION_NO_V="${VERSION#v}"
cd web/dist
zip -r ../../nodetool-web-${VERSION_NO_V}.zip .
cd ../..
ls -lh nodetool-web-${VERSION_NO_V}.zip
echo "archive_name=nodetool-web-${VERSION_NO_V}.zip" >> $GITHUB_OUTPUT
- name: Build Electron (Windows, unpacked)
if: matrix.os == 'windows-2022'
shell: bash -l {0}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEBUG: electron-builder
run: |
cd electron
npm run vite:build
npm run prepare-backend
npx electron-builder --config electron-builder.json --win --x64 --dir --publish never
- name: Resolve Windows artifacts (paths)
if: matrix.os == 'windows-2022'
shell: bash -l {0}
run: |
set -euo pipefail
VERSION_NO_V="${VERSION#v}"
VERSION_NO_V="${VERSION_NO_V//\//-}"
DIST_DIR="${GITHUB_WORKSPACE}/electron/dist"
WIN_UNPACKED_DIR="${DIST_DIR}/win-unpacked"
INSTALLER_PATH="${DIST_DIR}/Nodetool-Setup-${VERSION_NO_V}.exe"
if [ ! -d "${WIN_UNPACKED_DIR}" ]; then
echo "Expected win-unpacked not found: ${WIN_UNPACKED_DIR}"
ls -la "${DIST_DIR}" || true
exit 1
fi
APP_EXE="$(ls -1 "${WIN_UNPACKED_DIR}"/*.exe 2>/dev/null | head -n 1 || true)"
if [ -z "${APP_EXE}" ] || [ ! -f "${APP_EXE}" ]; then
echo "Could not find main app .exe in ${WIN_UNPACKED_DIR}"
exit 1
fi
echo "VERSION=${VERSION_NO_V}" >> "${GITHUB_ENV}"
echo "DIST_DIR=${DIST_DIR}" >> "${GITHUB_ENV}"
echo "WIN_UNPACKED_DIR=${WIN_UNPACKED_DIR}" >> "${GITHUB_ENV}"
echo "APP_EXE=${APP_EXE}" >> "${GITHUB_ENV}"
echo "INSTALLER_PATH=${INSTALLER_PATH}" >> "${GITHUB_ENV}"
- name: Verify Windows app-update.yml
if: matrix.os == 'windows-2022'
shell: bash -l {0}
run: |
set -euo pipefail
APP_UPDATE_YML="${WIN_UNPACKED_DIR}/resources/app-update.yml"
if [ ! -f "${APP_UPDATE_YML}" ]; then
echo "Expected app-update.yml not found at ${APP_UPDATE_YML}"
find "${WIN_UNPACKED_DIR}/resources" -maxdepth 2 -type f | sort | sed -n '1,120p' || true
exit 1
fi
node - <<'NODE'
const fs = require("fs");
const yaml = require("js-yaml");
const file = `${process.env.WIN_UNPACKED_DIR}/resources/app-update.yml`;
const doc = yaml.load(fs.readFileSync(file, "utf8")) || {};
for (const [key, expected] of Object.entries({
provider: "github",
owner: "nodetool-ai",
repo: "nodetool",
})) {
if (doc[key] !== expected) {
throw new Error(`${file} has ${key}=${doc[key] ?? "<missing>"}; expected ${expected}`);
}
}
if (doc.updaterCacheDirName !== "nodetool-updater") {
throw new Error(`${file} has updaterCacheDirName=${doc.updaterCacheDirName ?? "<missing>"}`);
}
console.log(`Verified ${file}`);
NODE
- name: Create Windows unpacked archive
if: matrix.os == 'windows-2022'
shell: bash -l {0}
run: |
set -euo pipefail
ARCHIVE_PATH="${DIST_DIR}/Nodetool-win-unpacked-${VERSION}.zip"
rm -f "${ARCHIVE_PATH}"
cd "${WIN_UNPACKED_DIR}"
powershell.exe -NoLogo -NoProfile -Command "Compress-Archive -Path * -DestinationPath '${ARCHIVE_PATH}' -Force"
echo "WIN_UNPACKED_ARCHIVE=${ARCHIVE_PATH}" >> "${GITHUB_ENV}"
- name: Decide whether to sign (skip test tags)
if: matrix.os == 'windows-2022' && (startsWith(github.ref, 'refs/tags/') || env.RELEASE_CHANNEL == 'nightly' || github.event.inputs.version != '')
shell: bash -l {0}
run: |
set -euo pipefail
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
SHOULD_SIGN="true"
if echo "${TAG}" | grep -Eiq -- '-test'; then
SHOULD_SIGN="false"
fi
echo "SHOULD_SIGN=${SHOULD_SIGN}" >> "${GITHUB_ENV}"
echo "Windows signing enabled: ${SHOULD_SIGN}"
- name: Sign main app exe (SSL.com eSigner)
if: matrix.os == 'windows-2022' && env.SHOULD_SIGN == 'true' && (startsWith(github.ref, 'refs/tags/') || env.RELEASE_CHANNEL == 'nightly' || github.event.inputs.version != '')
uses: sslcom/esigner-codesign@develop
with:
command: sign
username: ${{ secrets.ES_USERNAME }}
password: ${{ secrets.ES_PASSWORD }}
credential_id: ${{ secrets.CREDENTIAL_ID }}
totp_secret: ${{ secrets.ES_TOTP_SECRET }}
file_path: ${{ env.APP_EXE }}
output_path: ${{ env.DIST_DIR }}/signed-app
malware_block: true
environment_name: PROD
- name: Move signed app exe back to unpacked directory
if: matrix.os == 'windows-2022' && env.SHOULD_SIGN == 'true' && (startsWith(github.ref, 'refs/tags/') || env.RELEASE_CHANNEL == 'nightly' || github.event.inputs.version != '')
shell: bash -l {0}
run: |
set -euo pipefail
SIGNED_EXE="${DIST_DIR}/signed-app/$(basename "${APP_EXE}")"
if [ ! -f "${SIGNED_EXE}" ]; then
echo "Signed exe not found at ${SIGNED_EXE}"
ls -la "${DIST_DIR}/signed-app" || true
exit 1
fi
mv -f "${SIGNED_EXE}" "${APP_EXE}"
echo "Moved signed exe to ${APP_EXE}"
- name: Build NSIS installer from prepackaged app (Windows)
if: matrix.os == 'windows-2022'
shell: bash -l {0}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEBUG: electron-builder
run: |
cd electron
npx electron-builder --config electron-builder.json --config.win.signAndEditExecutable=false --win --x64 --prepackaged dist/win-unpacked --publish never
- name: Locate built Windows installer
if: matrix.os == 'windows-2022'
shell: bash -l {0}
run: |
set -euo pipefail
if [ ! -f "${INSTALLER_PATH}" ]; then
echo "Expected installer not at ${INSTALLER_PATH}; searching dist for Setup exe"
FOUND="$(ls -1 "${DIST_DIR}"/*Setup*.exe 2>/dev/null | head -n 1 || true)"
if [ -z "${FOUND}" ] || [ ! -f "${FOUND}" ]; then
echo "Could not find any Setup installer .exe in ${DIST_DIR}"
ls -la "${DIST_DIR}" || true
exit 1
fi
TARGET="${DIST_DIR}/Nodetool-Setup-${VERSION}.exe"
if [ "${FOUND}" != "${TARGET}" ]; then
echo "Renaming ${FOUND} -> ${TARGET}"
mv -f "${FOUND}" "${TARGET}"
fi
echo "INSTALLER_PATH=${TARGET}" >> "${GITHUB_ENV}"
else
echo "Installer found at ${INSTALLER_PATH}"
fi
- name: Sign installer exe (SSL.com eSigner)
if: matrix.os == 'windows-2022' && env.SHOULD_SIGN == 'true' && (startsWith(github.ref, 'refs/tags/') || env.RELEASE_CHANNEL == 'nightly' || github.event.inputs.version != '')
uses: sslcom/esigner-codesign@develop
with:
command: sign
username: ${{ secrets.ES_USERNAME }}
password: ${{ secrets.ES_PASSWORD }}
credential_id: ${{ secrets.CREDENTIAL_ID }}
totp_secret: ${{ secrets.ES_TOTP_SECRET }}
file_path: ${{ env.INSTALLER_PATH }}
output_path: ${{ env.DIST_DIR }}/signed-installer
malware_block: true
environment_name: PROD
- name: Move signed installer back to dist directory
if: matrix.os == 'windows-2022' && env.SHOULD_SIGN == 'true' && (startsWith(github.ref, 'refs/tags/') || env.RELEASE_CHANNEL == 'nightly' || github.event.inputs.version != '')
shell: bash -l {0}
run: |
set -euo pipefail
SIGNED_INSTALLER="${DIST_DIR}/signed-installer/$(basename "${INSTALLER_PATH}")"
if [ ! -f "${SIGNED_INSTALLER}" ]; then
echo "Signed installer not found at ${SIGNED_INSTALLER}"
ls -la "${DIST_DIR}/signed-installer" || true
exit 1
fi
mv -f "${SIGNED_INSTALLER}" "${INSTALLER_PATH}"
echo "Moved signed installer to ${INSTALLER_PATH}"
- name: Update latest.yml sha512/size for signed installer (Windows)
if: matrix.os == 'windows-2022' && env.SHOULD_SIGN == 'true' && (startsWith(github.ref, 'refs/tags/') || env.RELEASE_CHANNEL == 'nightly' || github.event.inputs.version != '')
shell: bash -l {0}
run: |
set -euo pipefail
cd electron
node - <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const yaml = require("js-yaml");
const distDir = process.env.DIST_DIR;
const version = process.env.VERSION;
const installerName = `Nodetool-Setup-${version}.exe`;
const installerPath = process.env.INSTALLER_PATH;
const latestPath = path.join(distDir, process.env.UPDATE_MANIFEST || "latest.yml");
if (!fs.existsSync(latestPath)) {
console.warn(`update manifest not found at ${latestPath}; skipping update`);
process.exit(0);
}
if (!fs.existsSync(installerPath)) {
console.error(`Installer not found at ${installerPath}`);
process.exit(1);
}
const buf = fs.readFileSync(installerPath);
const sha512 = crypto.createHash("sha512").update(buf).digest("base64");
const size = buf.length;
const doc = yaml.load(fs.readFileSync(latestPath, "utf8")) || {};
doc.path = installerName;
doc.sha512 = sha512;
if (Array.isArray(doc.files)) {
for (const f of doc.files) {
if (!f || typeof f !== "object") continue;
if (typeof f.url === "string" && f.url.includes(installerName)) {
f.sha512 = sha512;
f.size = size;
}
}
}
if (Array.isArray(doc.files)) {
doc.files = doc.files.filter((f) => {
if (!f || typeof f !== "object") return true;
const url = typeof f.url === "string" ? f.url : "";
return !url.endsWith(".blockmap");
});
}
const staleBlockmapPath = `${installerPath}.blockmap`;
if (fs.existsSync(staleBlockmapPath)) {
fs.rmSync(staleBlockmapPath);
console.log(`Removed stale signed-installer blockmap: ${staleBlockmapPath}`);
}
fs.writeFileSync(latestPath, yaml.dump(doc, { lineWidth: 120 }), "utf8");
console.log(`Updated ${path.basename(latestPath)} for signed installer: sha512=${sha512.slice(0, 16)}... size=${size}`);
NODE
- name: Build Electron (macOS/Linux)
if: matrix.os != 'windows-2022'
shell: bash
env:
NODE_OPTIONS: "--max_old_space_size=4096"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEBUG: electron-builder
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
ulimit -n 65536
cd electron
npm run vite:build
npm run prepare-backend
npx electron-builder --config electron-builder.json --publish never
- name: Smoke-launch macOS .app
if: matrix.os == 'macos-latest'
shell: bash
run: |
set -euo pipefail
# Spawn the unpacked .app's main binary. We don't need a window —
# any dyld/library-validation/native-module load failure aborts
# the process within ~2 seconds, which is exactly what we want to
# catch (missing Electron Framework, sharp leak, broken sign, ...).
# If the process is still alive after the timeout it means the
# main bundle loaded cleanly.
fail=0
shopt -s nullglob
for app in electron/dist/mac-arm64/Nodetool.app electron/dist/mac/Nodetool.app; do
[ -d "$app" ] || continue
echo "::group::Smoke-launch $app"
bin="$app/Contents/MacOS/Nodetool"
log=$(mktemp)
# Force a quick exit path: --version / --help would short-circuit, but
# we want to actually run the main bundle. Launch in background, give
# it 15s to either die (= bad) or stay alive (= good), then SIGTERM.
"$bin" >"$log" 2>&1 &
pid=$!
alive=0
for _ in $(seq 1 15); do
sleep 1
if ! kill -0 $pid 2>/dev/null; then
alive=0
break
fi
alive=1
done
if [ "$alive" -eq 0 ]; then
wait $pid 2>/dev/null || true
rc=$?
echo "::error file=$app::App died at launch (exit $rc)"
echo "--- launch log ---"
tail -200 "$log" || true
fail=1
else
echo " OK: process survived 15s, terminating"
kill -TERM $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
fi
rm -f "$log"
echo "::endgroup::"
done
if [ "$fail" -ne 0 ]; then
echo "::error::Smoke launch failed; refusing to upload."
exit 1
fi
- name: Verify macOS DMG/zip integrity
if: matrix.os == 'macos-latest'
shell: bash
run: |
set -euo pipefail
# Guard against electron-builder/dmgbuild regressions that silently
# drop files from the produced artifacts. The observed failure mode:
# dmgbuild's auto-sized staging image is too small for the app's real
# on-disk footprint (thousands of small files inflate the HFS catalog),
# so `ditto` fails on the last large file (the ~150 MB Electron
# Framework Mach-O) with "No space left on device" — with ~130 GB of
# host disk still free. electron-builder only logs the stderr and
# continues, shipping a DMG that mounts fine yet aborts at launch
# ("Nodetool cannot be opened because of a problem"). The fix is the
# explicit `dmg.size` in electron-builder.json; this guard stays as the
# backstop. The smoke-launch above only exercises the *unpacked* app,
# so it cannot catch a broken DMG/zip. Mount each DMG and assert the
# framework binary is present and >50 MB; do the same for the .zip.
fail=0
shopt -s nullglob
find_framework_binary() {
local framework_dir="$1"
# Electron packaging can produce either a versioned framework layout
# or a framework-level binary symlink/copy depending on how
# electron-builder materializes the app bundle in each artifact.
local candidate_versioned="$framework_dir/Versions/A/Electron Framework"
local candidate_flat="$framework_dir/Electron Framework"
if [ -f "$candidate_versioned" ]; then
printf '%s\n' "$candidate_versioned"
return 0
fi
if [ -f "$candidate_flat" ]; then
printf '%s\n' "$candidate_flat"
return 0
fi
return 1
}
for dmg in electron/dist/Nodetool-*.dmg; do
echo "::group::Verify $dmg"
mountpoint=$(hdiutil attach -nobrowse -readonly -plist "$dmg" \
| /usr/bin/python3 -c "import sys,plistlib; p=plistlib.loads(sys.stdin.buffer.read()); print(next(e['mount-point'] for e in p['system-entities'] if 'mount-point' in e))")
framework_dir="$mountpoint/Nodetool.app/Contents/Frameworks/Electron Framework.framework"
if ! fw="$(find_framework_binary "$framework_dir")"; then
echo "::error file=$dmg::Missing Electron Framework binary (checked Versions/A and framework-level Electron Framework)"
ls -la "$framework_dir/" || true
if [ -d "$framework_dir/Versions" ]; then
ls -la "$framework_dir/Versions/" || true
fi
fail=1
else
size=$(stat -f %z "$fw")
if [ "$size" -lt 52428800 ]; then
echo "::error file=$dmg::Electron Framework binary suspiciously small: ${size} bytes"
fail=1
else
echo " OK: Electron Framework $(($size / 1048576)) MB at $fw"
fi
fi
hdiutil detach -quiet "$mountpoint" || hdiutil detach -force "$mountpoint" || true
echo "::endgroup::"
done
# Also sanity-check the .zip (electron-updater consumes this).
for zip in electron/dist/Nodetool-*.zip; do
echo "::group::Verify $zip"
entry_versioned="Nodetool.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework"
entry_flat="Nodetool.app/Contents/Frameworks/Electron Framework.framework/Electron Framework"
# Keep diagnostics concise while still showing enough context to debug.
# 120 lines ~= listing headers + ~100 entries, balancing debuggability
# with CI log volume.
diag_limit=120
# Path contains spaces, so don't use $NF — match the first numeric line in the listing body.
size=$(unzip -l "$zip" "$entry_versioned" 2>/dev/null | awk 'NR>3 && $1 ~ /^[0-9]+$/ {print $1; exit}')
if [ -z "${size:-}" ]; then
size=$(unzip -l "$zip" "$entry_flat" 2>/dev/null | awk 'NR>3 && $1 ~ /^[0-9]+$/ {print $1; exit}')
fi
if [ -z "${size:-}" ] || [ "${size:-0}" -lt 52428800 ]; then
echo "::error file=$zip::Electron Framework missing or too small in zip (size=${size:-0})"
unzip -l "$zip" "Nodetool.app/Contents/Frameworks/Electron Framework.framework/*" 2>/dev/null | sed -n "1,${diag_limit}p" || true
unzip -l "$zip" "Nodetool.app/Contents/Frameworks/Electron Framework.framework/Versions/*" 2>/dev/null | sed -n "1,${diag_limit}p" || true
fail=1
else
echo " OK: Electron Framework $(($size / 1048576)) MB"
fi
echo "::endgroup::"
done
if [ "$fail" -ne 0 ]; then
echo "::error::macOS artifact verification failed; refusing to upload."
exit 1
fi
# ── Workflow artifacts (downloadable from the Actions run page) ────
- name: Upload web build artifact
if: matrix.os == 'ubuntu-latest' && always()
uses: actions/upload-artifact@v7
with:
name: nodetool-web
retention-days: 7
if-no-files-found: ignore
path: nodetool-web-*.zip
- name: Upload build artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: electron-${{ matrix.os }}
retention-days: 7
if-no-files-found: ignore
path: |
electron/dist/*.dmg
electron/dist/*.zip
electron/dist/*.AppImage
electron/dist/*.exe
electron/dist/*.blockmap
electron/dist/*.yml
nightly-docker-tag:
name: Retag GHCR image for nightly
needs: [preflight]
if: ${{ !failure() && !cancelled() && needs.preflight.outputs.release_channel == 'nightly' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# The `docker.yml` workflow already built and pushed
# `ghcr.io/nodetool-ai/nodetool:main-<short_sha>` when this commit landed
# on main. Reuse that manifest under the nightly version tag instead of
# rebuilding. `buildx imagetools create` only writes a new tag pointing
# at the existing manifest — no layer pulls or pushes.
- name: Retag main-<sha> to nightly version
env:
VERSION: ${{ needs.preflight.outputs.version }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
short_sha="${SHA:0:7}"
src="ghcr.io/nodetool-ai/nodetool:main-${short_sha}"
version_tag="ghcr.io/nodetool-ai/nodetool:${VERSION}"
nightly_tag="ghcr.io/nodetool-ai/nodetool:nightly"
# The docker.yml build for the same commit may still be running.
# Poll for the source manifest before retagging.
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if docker buildx imagetools inspect "$src" > /dev/null 2>&1; then
echo "Found source image: $src"
break
fi
echo "Source image $src not found yet (attempt $attempt/10); sleeping 60s..."
sleep 60
done
if ! docker buildx imagetools inspect "$src" > /dev/null 2>&1; then
echo "Source image $src never appeared; failing." >&2
exit 1
fi
docker buildx imagetools create \
--tag "$version_tag" \
--tag "$nightly_tag" \
"$src"
echo "Retagged $src -> $version_tag, $nightly_tag"
publish-release:
name: Publish GitHub Release
needs: [preflight, release-build]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release-build.result == 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.channel == 'nightly' || github.event.inputs.version != '') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ needs.preflight.outputs.ref }}
- name: Download release artifacts
uses: actions/download-artifact@v8
with:
pattern: '*'
merge-multiple: true
path: release-assets
- name: Validate release assets
run: node scripts/validate-release-assets.mjs release-assets "${{ needs.preflight.outputs.release_channel }}"
- name: Smoke-test updater manifests
run: node scripts/smoke-release-updater-assets.mjs release-assets "${{ needs.preflight.outputs.version }}" "${{ needs.preflight.outputs.release_channel }}"
# The win-unpacked zip is kept as a workflow artifact for debugging,
# but is intentionally NOT attached to the GitHub Release.
# Drop it from release-assets/ here so the publish step doesn't need a
# negation glob (which would fail when no win-unpacked zip is present
# and `fail_on_unmatched_files: true` is set).
- name: Drop win-unpacked archives from release assets
run: |
set -euo pipefail
shopt -s nullglob
removed=0
for f in release-assets/*win-unpacked*.zip; do
echo "Removing from release-assets: $f"
rm -f "$f"
removed=$((removed + 1))
done
echo "Removed ${removed} win-unpacked archive(s)."
- name: Publish release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.preflight.outputs.tag }}
name: ${{ needs.preflight.outputs.release_name }}
prerelease: ${{ needs.preflight.outputs.is_prerelease }}
make_latest: ${{ needs.preflight.outputs.make_latest }}
files: |
release-assets/*.zip
release-assets/*.dmg
release-assets/*.AppImage
release-assets/*.exe
release-assets/*.blockmap
release-assets/*.yml
fail_on_unmatched_files: true