Bump version to 0.7.0-rc.32 #689
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |