diff --git a/install.sh b/install.sh index 16968a9bcf8..add4c49fb8e 100755 --- a/install.sh +++ b/install.sh @@ -67,12 +67,12 @@ clone_nemoclaw_ref() { git init --quiet "$dest" git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git - if ! git -C "$dest" fetch --quiet --depth 1 origin "$ref"; then + if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then printf "[ERROR] Requested install ref '%s' is not available from https://github.com/NVIDIA/NemoClaw.git.\n" "$ref" >&2 printf " Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again.\n" >&2 exit 1 fi - git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach FETCH_HEAD + git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target } exec_installer_from_ref() { diff --git a/scripts/install.sh b/scripts/install.sh index 0987e3b430b..c62c1385f70 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -66,7 +66,18 @@ resolve_installer_version() { printf "%s" "${NEMOCLAW_INSTALL_REF#v}" return fi - # Prefer git tags (works in dev clones and CI) + # Prefer the .version file stamped during install: it records the exact + # requested tag, while describe on a shallow clone can resolve a different + # nearby tag. + if [[ -f "${repo_root}/.version" ]]; then + local file_ver + file_ver="$(cat "${repo_root}/.version")" + if [[ -n "$file_ver" ]]; then + printf "%s" "$file_ver" + return + fi + fi + # Fall back to git tags (dev clones and CI have no .version) if command -v git &>/dev/null && [[ -e "${repo_root}/.git" ]]; then local git_ver="" if git_ver="$(git -C "$repo_root" describe --tags --match 'v*' 2>/dev/null)"; then @@ -77,15 +88,6 @@ resolve_installer_version() { fi fi fi - # Fall back to .version file (stamped during install) - if [[ -f "${repo_root}/.version" ]]; then - local file_ver - file_ver="$(cat "${repo_root}/.version")" - if [[ -n "$file_ver" ]]; then - printf "%s" "$file_ver" - return - fi - fi # Last resort: package.json local package_json="${repo_root}/package.json" local version="" @@ -151,15 +153,32 @@ resolve_release_tag() { printf "%s" "${NEMOCLAW_INSTALL_TAG:-$DEFAULT_INSTALL_REF}" } +# Map an install ref to the version string to stamp into .version. Prints the +# semver for an immutable version tag; prints nothing for mutable (lkg/latest) +# or non-version refs so callers fall back to git describe. +resolve_stamped_version() { + local ref="${1:-}" version="" + case "$ref" in + refs/tags/*) version="${ref#refs/tags/}" ;; + *) version="$ref" ;; + esac + version="${version#v}" + if is_mutable_install_ref "$ref" \ + || ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + return 0 + fi + printf "%s" "$version" +} + clone_nemoclaw_ref() { local ref="$1" dest="$2" git init --quiet "$dest" git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git - if ! git -C "$dest" fetch --quiet --depth 1 origin "$ref"; then + if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then error "Requested install ref '$ref' is not available from https://github.com/NVIDIA/NemoClaw.git. Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again." fi - git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach FETCH_HEAD + git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target } # --------------------------------------------------------------------------- @@ -1714,10 +1733,16 @@ install_nemoclaw() { # --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 + # Stamp .version from the requested ref so the recorded version matches the + # installed tag, even when a shallow clone cannot name it via describe. + local stamped_version + stamped_version="$(resolve_stamped_version "$release_ref")" + if [[ -n "$stamped_version" ]]; then + printf '%s' "$stamped_version" >"$nemoclaw_src/.version" + else + git -C "$nemoclaw_src" describe --tags --match 'v*' 2>/dev/null \ + | sed 's/^v//' >"$nemoclaw_src/.version" || true + fi if [[ -z "${NEMOCLAW_AGENT:-}" || "${NEMOCLAW_AGENT}" == "openclaw" ]]; then spin "Preparing OpenClaw package" bash -c "$(declare -f info warn resolve_openclaw_version pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" diff --git a/test/install-clone-ref.test.ts b/test/install-clone-ref.test.ts index c2675b92638..9601cc363d9 100644 --- a/test/install-clone-ref.test.ts +++ b/test/install-clone-ref.test.ts @@ -56,4 +56,127 @@ describe("installer git checkout", () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it("checks out a lightweight tag at its commit, not the branch tip (#7474)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-clone-tag-")); + const origin = path.join(tmp, "origin"); + fs.mkdirSync(origin); + const git = (args: string[], cwd = origin) => spawnSync("git", args, { cwd, encoding: "utf8" }); + + try { + expect(git(["init", "--initial-branch=main"]).status).toBe(0); + expect(git(["config", "user.name", "NemoClaw Test"]).status).toBe(0); + expect(git(["config", "user.email", "nemoclaw-test@example.invalid"]).status).toBe(0); + fs.writeFileSync(path.join(origin, "README.md"), "tagged\n"); + expect(git(["add", "README.md"]).status).toBe(0); + expect(git(["-c", "commit.gpgsign=false", "commit", "-m", "tagged release"]).status).toBe(0); + const taggedHead = git(["rev-parse", "HEAD"]).stdout.trim(); + expect(git(["-c", "tag.gpgSign=false", "tag", "v9.9.9"]).status).toBe(0); + fs.writeFileSync(path.join(origin, "README.md"), "post-release\n"); + expect(git(["add", "README.md"]).status).toBe(0); + expect(git(["-c", "commit.gpgsign=false", "commit", "-m", "post release"]).status).toBe(0); + expect(git(["rev-parse", "HEAD"]).stdout.trim()).not.toBe(taggedHead); + + for (const [index, installer] of [INSTALLER_PAYLOAD, CURL_PIPE_INSTALLER].entries()) { + const destination = path.join(tmp, `checkout-${index}`); + const result = spawnSync( + "bash", + ["-c", 'source "$INSTALLER_UNDER_TEST"\nclone_nemoclaw_ref v9.9.9 "$DESTINATION"'], + { + encoding: "utf8", + env: { + ...process.env, + DESTINATION: destination, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.file://${origin}.insteadOf`, + GIT_CONFIG_VALUE_0: "https://github.com/NVIDIA/NemoClaw.git", + INSTALLER_UNDER_TEST: installer, + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(git(["-C", destination, "rev-parse", "HEAD"], tmp).stdout.trim()).toBe(taggedHead); + expect(git(["-C", destination, "symbolic-ref", "-q", "HEAD"], tmp).status).not.toBe(0); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe("installer version stamping", () => { + const extract = (stdout: string) => stdout.match(/START([\s\S]*?)STOP/)?.[1] ?? null; + + it("stamps a requested version tag and defers mutable refs to describe (#7474)", () => { + for (const installer of [INSTALLER_PAYLOAD, CURL_PIPE_INSTALLER]) { + const stamp = (ref: string) => { + const result = spawnSync( + "bash", + [ + "-c", + 'source "$INSTALLER_UNDER_TEST"\nprintf START\nresolve_stamped_version "$REF"\nprintf STOP', + ], + { encoding: "utf8", env: { ...process.env, INSTALLER_UNDER_TEST: installer, REF: ref } }, + ); + expect(result.status, result.stderr).toBe(0); + return extract(result.stdout); + }; + expect(stamp("v0.0.93")).toBe("0.0.93"); + expect(stamp("refs/tags/v1.2.3")).toBe("1.2.3"); + expect(stamp("lkg")).toBe(""); + expect(stamp("latest")).toBe(""); + expect(stamp("main")).toBe(""); + } + }); + + it("reports the stamped .version over a mismatched git describe (#7474)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-version-")); + const git = (args: string[]) => spawnSync("git", args, { cwd: tmp, encoding: "utf8" }); + + try { + fs.writeFileSync(path.join(tmp, "package.json"), `${JSON.stringify({ version: "0.0.1" })}\n`); + expect(git(["init", "--initial-branch=main"]).status).toBe(0); + expect(git(["config", "user.name", "NemoClaw Test"]).status).toBe(0); + expect(git(["config", "user.email", "nemoclaw-test@example.invalid"]).status).toBe(0); + fs.writeFileSync(path.join(tmp, "README.md"), "release\n"); + expect(git(["add", "."]).status).toBe(0); + expect(git(["-c", "commit.gpgsign=false", "commit", "-m", "release"]).status).toBe(0); + expect( + git(["-c", "tag.gpgSign=false", "tag", "-a", "v0.0.38", "-m", "old release"]).status, + ).toBe(0); + + const resolve = (installer: string) => + spawnSync( + "bash", + [ + "-c", + 'source "$INSTALLER_UNDER_TEST"\nprintf START\nresolve_installer_version\nprintf STOP', + ], + { + encoding: "utf8", + env: { + ...process.env, + INSTALLER_UNDER_TEST: installer, + NEMOCLAW_REPO_ROOT: tmp, + NEMOCLAW_INSTALL_REF: "", + NEMOCLAW_INSTALL_TAG: "", + }, + }, + ); + + for (const installer of [INSTALLER_PAYLOAD, CURL_PIPE_INSTALLER]) { + fs.writeFileSync(path.join(tmp, ".version"), "0.0.93"); + const withStamp = resolve(installer); + expect(withStamp.status, withStamp.stderr).toBe(0); + expect(extract(withStamp.stdout)).toBe("0.0.93"); + + fs.rmSync(path.join(tmp, ".version")); + const withoutStamp = resolve(installer); + expect(withoutStamp.status, withoutStamp.stderr).toBe(0); + expect(extract(withoutStamp.stdout)).toBe("0.0.38"); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 53cc29cb36e..c8faba1e457 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -3404,7 +3404,7 @@ exit 0`, expect(result.status).not.toBe(0); expect(output).toMatch(/Requested install ref 'v9\.9\.9' is not available/); expect(output).toMatch(/Check NEMOCLAW_INSTALL_TAG\/NEMOCLAW_INSTALL_REF/); - expect(fs.readFileSync(gitLog, "utf-8")).toMatch(/fetch --quiet --depth 1 origin v9\.9\.9/); + expect(fs.readFileSync(gitLog, "utf-8")).toMatch(/\+v9\.9\.9:refs\/nemoclaw-install\/target/); }); it("falls back to the legacy root installer when the selected ref only has the old scripts/install.sh wrapper", () => {