Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Build artifacts and caches
.version
*.pyc
*.tsbuildinfo
.pytest_cache/
Expand Down
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions bin/lib/version.js
Original file line number Diff line number Diff line change
@@ -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 };
7 changes: 3 additions & 4 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down Expand Up @@ -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"
Expand Down
96 changes: 96 additions & 0 deletions scripts/check-version-tag-sync.sh
Original file line number Diff line number Diff line change
@@ -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 "<local-ref> <local-sha> <remote-ref> <remote-sha>" | 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 <<EOF

${RED}✗ Version mismatch for tag ${tag}${RESET}

Tag version: ${tag_version}
package.json version: ${pkg_version}

Update package.json before tagging:

${DIM}npm version ${tag_version} --no-git-tag-version
git add package.json
git commit --amend --no-edit
git tag -f ${tag}${RESET}

EOF
return 1
fi

echo "${GREEN}✓${RESET} Tag ${tag} matches package.json (${pkg_version})"
return 0
}

# ------------------------------------------------------------------
# --check mode: compare the latest v* tag against current package.json
# ------------------------------------------------------------------
if [[ "${1:-}" == "--check" ]]; then
latest_tag="$(git -C "$ROOT" describe --tags --match 'v*' --abbrev=0 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
45 changes: 38 additions & 7 deletions test/install-preflight.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Expand Down Expand Up @@ -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",
Expand Down
Loading