diff --git a/.gitignore b/.gitignore index 4047e0fe2b..d1991c87c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Build artifacts and caches +.version *.pyc *.tsbuildinfo .pytest_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 28919fe8a5..3b2ee25e5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -213,6 +213,15 @@ repos: stages: [pre-push] priority: 10 + - id: version-tag-sync + name: package.json ↔ git tag version sync + entry: bash scripts/check-version-tag-sync.sh + language: system + always_run: true + pass_filenames: false + stages: [pre-push] + priority: 10 + # ── Priority 20: project-level checks (coverage + ratchet) ───────────────── - repo: local hooks: diff --git a/bin/lib/version.js b/bin/lib/version.js new file mode 100644 index 0000000000..2aabb638d8 --- /dev/null +++ b/bin/lib/version.js @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Resolve the NemoClaw version from (in order): + * 1. `git describe --tags --match "v*"` — works in dev / source checkouts + * 2. `.version` file at repo root — stamped at publish time + * 3. `package.json` version — hard-coded fallback + */ + +const { execFileSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); + +const ROOT = path.resolve(__dirname, "..", ".."); + +function getVersion() { + // 1. Try git (available in dev clones and CI) + try { + const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { + cwd: ROOT, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + // raw looks like "v0.3.0" or "v0.3.0-4-gabcdef1" + if (raw) return raw.replace(/^v/, ""); + } catch { + // no git, or no matching tags — fall through + } + + // 2. Try .version file (stamped by prepublishOnly) + try { + const ver = fs.readFileSync(path.join(ROOT, ".version"), "utf-8").trim(); + if (ver) return ver; + } catch { + // not present — fall through + } + + // 3. Fallback to package.json + return require(path.join(ROOT, "package.json")).version; +} + +module.exports = { getVersion }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index d19317c16d..e45c80cc92 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -42,6 +42,7 @@ const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); const { parseGatewayInference } = require("./lib/inference-config"); +const { getVersion } = require("./lib/version"); const onboardSession = require("./lib/onboard-session"); const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); @@ -1123,9 +1124,8 @@ async function sandboxDestroy(sandboxName, args = []) { // ── Help ───────────────────────────────────────────────────────── function help() { - const pkg = require(path.join(__dirname, "..", "package.json")); console.log(` - ${B}${G}NemoClaw${R} ${D}v${pkg.version}${R} + ${B}${G}NemoClaw${R} ${D}v${getVersion()}${R} ${D}Deploy more secure, always-on AI assistants with a single command.${R} ${G}Getting Started:${R} @@ -1216,8 +1216,7 @@ const [cmd, ...args] = process.argv.slice(2); break; case "--version": case "-v": { - const pkg = require(path.join(__dirname, "..", "package.json")); - console.log(`nemoclaw v${pkg.version}`); + console.log(`nemoclaw v${getVersion()}`); break; } default: diff --git a/install.sh b/install.sh index a902d2c91f..1ecc18ebda 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,27 @@ DEFAULT_NEMOCLAW_VERSION="0.1.0" TOTAL_STEPS=3 resolve_installer_version() { + # Prefer git tags (works in dev clones and CI) + if command -v git &>/dev/null && [[ -d "${SCRIPT_DIR}/.git" ]]; then + local git_ver="" + if git_ver="$(git -C "$SCRIPT_DIR" describe --tags --match 'v*' 2>/dev/null)"; then + git_ver="${git_ver#v}" + if [[ -n "$git_ver" ]]; then + printf "%s" "$git_ver" + return + fi + fi + fi + # Fall back to .version file (stamped during install) + if [[ -f "${SCRIPT_DIR}/.version" ]]; then + local file_ver + file_ver="$(cat "${SCRIPT_DIR}/.version")" + if [[ -n "$file_ver" ]]; then + printf "%s" "$file_ver" + return + fi + fi + # Last resort: package.json local package_json="${SCRIPT_DIR}/package.json" local version="" if [[ -f "$package_json" ]]; then @@ -693,6 +714,14 @@ install_nemoclaw() { rm -rf "$nemoclaw_src" mkdir -p "$(dirname "$nemoclaw_src")" spin "Cloning NemoClaw source" git clone --depth 1 --branch "$release_ref" https://github.com/NVIDIA/NemoClaw.git "$nemoclaw_src" + # Fetch version tags into the shallow clone so `git describe --tags + # --match "v*"` works at runtime (the shallow clone only has the + # single ref we asked for). + git -C "$nemoclaw_src" fetch --depth=1 origin 'refs/tags/v*:refs/tags/v*' 2>/dev/null || true + # Also stamp .version as a fallback for environments where git is + # unavailable or tags are pruned later. + git -C "$nemoclaw_src" describe --tags --match 'v*' 2>/dev/null \ + | sed 's/^v//' >"$nemoclaw_src/.version" || true spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" diff --git a/scripts/check-version-tag-sync.sh b/scripts/check-version-tag-sync.sh new file mode 100755 index 0000000000..af1a1d669b --- /dev/null +++ b/scripts/check-version-tag-sync.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Pre-push hook: when pushing a v* tag, verify that package.json at the +# tagged commit has a matching version. Blocks the push if they differ. +# +# Usage (called by prek as a pre-push hook): +# echo " " | bash scripts/check-version-tag-sync.sh +# +# Manual check (no stdin needed — compares latest v* tag with package.json): +# bash scripts/check-version-tag-sync.sh --check + +set -euo pipefail + +RED=$'\033[1;31m' +GREEN=$'\033[32m' +DIM=$'\033[2m' +RESET=$'\033[0m' + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Extract the "version" field from the package.json at a given commit. +version_at_commit() { + local sha="$1" + git -C "$ROOT" show "${sha}:package.json" 2>/dev/null \ + | sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' \ + | head -1 +} + +check_tag() { + local tag="$1" sha="$2" + local tag_version="${tag#v}" + local pkg_version + pkg_version="$(version_at_commit "$sha")" + + if [[ -z "$pkg_version" ]]; then + echo "${RED}✗${RESET} Tag ${tag}: could not read package.json at ${sha:0:8}" >&2 + return 1 + fi + + if [[ "$pkg_version" != "$tag_version" ]]; then + cat >&2 </dev/null || true)" + if [[ -z "$latest_tag" ]]; then + echo "${DIM}No v* tags found — nothing to check.${RESET}" + exit 0 + fi + sha="$(git -C "$ROOT" rev-list -1 "$latest_tag")" + check_tag "$latest_tag" "$sha" + exit $? +fi + +# ------------------------------------------------------------------ +# Pre-push mode: read pushed refs from stdin +# ------------------------------------------------------------------ +errors=0 + +while IFS=' ' read -r local_ref local_sha _remote_ref _remote_sha; do + # Only care about v* tag pushes + case "$local_ref" in + refs/tags/v*) + tag="${local_ref#refs/tags/}" + check_tag "$tag" "$local_sha" || errors=$((errors + 1)) + ;; + esac +done + +if ((errors > 0)); then + exit 1 +fi diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index d27f37fef6..0300710298 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -902,8 +902,14 @@ exit 0`, }); expect(result.status).toBe(0); - // git should NOT have been called at all in the source-checkout path - expect(fs.existsSync(gitLog)).toBe(false); + // git clone / git fetch should NOT have been called in the source-checkout path. + // git may be called for version resolution (git describe), so we check + // that no clone or fetch was attempted rather than no git calls at all. + if (fs.existsSync(gitLog)) { + const gitCalls = fs.readFileSync(gitLog, "utf-8"); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); + } // And curl for the releases API should NOT have been called expect(`${result.stdout}${result.stderr}`).not.toMatch(/curl should not be called/); }); @@ -1043,18 +1049,43 @@ describe("installer pure helpers", () => { // -- resolve_installer_version -- - it("resolve_installer_version: reads version from package.json", () => { + it("resolve_installer_version: reads version from git or package.json", () => { const r = callInstallerFn("resolve_installer_version"); - // Should read from the repo's actual package.json - expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + // May return clean semver ("0.0.2") or git describe format ("0.0.2-3-gabcdef1") + expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(-.+)?$/); + }); + + it("resolve_installer_version: falls back to package.json when git tags are unavailable", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-pkg-")); + fs.mkdirSync(path.join(tmp, ".git")); + fs.writeFileSync( + path.join(tmp, "package.json"), + `${JSON.stringify({ version: "0.5.0" }, null, 2)}\n`, + ); + // source overwrites SCRIPT_DIR, so we re-set it after sourcing. + // The temp dir advertises git metadata but has no usable tags, + // so the function should fall back to package.json instead of exiting. + const r = spawnSync( + "bash", + ["-c", `source "${INSTALLER}" 2>/dev/null; SCRIPT_DIR="${tmp}"; resolve_installer_version`], + { + cwd: tmp, + encoding: "utf-8", + env: { HOME: tmp, PATH: TEST_SYSTEM_PATH }, + }, + ); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("0.5.0"); }); it("resolve_installer_version: falls back to DEFAULT when no package.json", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-")); - // source from a directory with no package.json — SCRIPT_DIR will be wrong + // source overwrites SCRIPT_DIR, so we re-set it after sourcing. + // The temp dir has no .git, no .version, and no package.json, + // so the function should fall back to DEFAULT_NEMOCLAW_VERSION. const r = spawnSync( "bash", - ["-c", `SCRIPT_DIR="${tmp}"; source "${INSTALLER}" 2>/dev/null; resolve_installer_version`], + ["-c", `source "${INSTALLER}" 2>/dev/null; SCRIPT_DIR="${tmp}"; resolve_installer_version`], { cwd: tmp, encoding: "utf-8",