local M = {} local is_windows = (package.config:sub(1, 1) == '\\') --- Shell-quote a string for safe interpolation into a command. ---@param s string ---@return string local function shell_quote(s) if is_windows then return '"' .. s:gsub('"', '\\"') .. '"' end return "'" .. s:gsub("'", "'\\''") .. "'" end --- Run a git command in the given repository and return trimmed stdout. ---@param repo_root string ---@return string|nil output trimmed stdout, or nil on failure local function git(repo_root, ...) local parts = { 'git', '-C', shell_quote(repo_root) } for i = 1, select('#', ...) do parts[#parts + 1] = shell_quote(select(i, ...)) end local redirect = is_windows and ' 2>NUL' or ' 2>/dev/null' local handle = io.popen(table.concat(parts, ' ') .. redirect) if not handle then return nil end local output = handle:read('*a') handle:close() if not output or output:match('^%s*$') then return nil end return output:gsub('%s+$', '') end function M.current_release_tag(repo_root) local raw = git(repo_root, 'tag', '--points-at', 'HEAD') if not raw then return nil end -- A nightly commit carries both the per-sha tag (e.g. 0.9.2-nightly.abc1234, -- which owns a permanent release with that commit's binaries) and the rolling -- `nightly` alias. Prefer the per-sha tag so a pinned install downloads the -- binary built for its own commit, not whatever `nightly` currently points at. local stable, nightly_versioned, nightly_alias, dev, other for tag in raw:gmatch('[^\n]+') do if tag:match('^v%d') then stable = tag elseif tag:match('%-nightly%.') then nightly_versioned = tag elseif tag == 'nightly' then nightly_alias = tag elseif tag:match('%-dev%.') then dev = tag else other = tag end end return stable or nightly_versioned or dev or other or nightly_alias end function M.read_base_version(repo_root) local cargo_path = repo_root .. '/crates/fff-core/Cargo.toml' local f = io.open(cargo_path, 'r') if not f then return nil end for line in f:lines() do local ver = line:match('^version%s*=%s*"([^"]+)"') if ver then f:close() return ver end end f:close() return nil end ---@class FFFVersionInfo ---@field version string semver version (e.g. "0.4.0" or "0.4.1-nightly.abc1234") ---@field release_tag string permanent GitHub release tag clients download from (per-sha for nightlies) ---@field is_release boolean true for tagged stable releases ---@field npm_tag string "latest"|"nightly"|"dev" --- Bump the patch component of a semver string. --- "1.2.3" → "1.2.4" ---@param version string ---@return string|nil bumped version, or nil if parsing fails local function bump_patch(version) local major, minor, patch = version:match('^(%d+)%.(%d+)%.(%d+)') if not major then return nil end return string.format('%s.%s.%d', major, minor, tonumber(patch) + 1) end --- Compute the version for a new release based on git state. --- Used by CI to determine what tag to create — NOT for downloads. --- --- For prerelease versions the patch is bumped so that the result is --- higher than the current Cargo.toml version in semver ordering. --- This is required for `cargo set-version` / crates.io publishing --- (0.4.1-nightly.x > 0.4.0, whereas 0.4.0-nightly.x < 0.4.0). --- --- tagged release (v*) → version from tag, npm_tag = "latest" --- main branch → {base+1}-nightly.{sha}, npm_tag = "nightly" --- detached HEAD → {base+1}-nightly.{sha}, npm_tag = "nightly" --- other branch (PR / feature) → {base+1}-dev.{sha}, npm_tag = "dev" --- ---@param repo_root string absolute path to the repository root ---@return FFFVersionInfo|nil info ---@return string|nil err function M.resolve(repo_root) local tag = git(repo_root, 'describe', '--exact-match', '--tags', '--match', 'v*', 'HEAD') if tag and tag:match('^v%d') then return { version = tag:sub(2), release_tag = tag, is_release = true, npm_tag = 'latest', } end local short_sha = git(repo_root, 'rev-parse', '--short', 'HEAD') if not short_sha then return nil, 'Failed to determine git SHA' end local base_version = M.read_base_version(repo_root) if not base_version then return nil, 'Could not read base version from crates/fff-core/Cargo.toml' end local next_version = bump_patch(base_version) if not next_version then return nil, 'Could not parse base version: ' .. base_version end local branch = git(repo_root, 'symbolic-ref', '--short', 'HEAD') local prerelease_label, npm_tag if not branch then prerelease_label = 'nightly' npm_tag = 'nightly' elseif branch == 'main' or branch == 'fix/download-version' then prerelease_label = 'nightly' npm_tag = 'nightly' else prerelease_label = 'dev' npm_tag = 'dev' end local version = string.format('%s-%s.%s', next_version, prerelease_label, short_sha) -- Each nightly gets its own permanent per-sha release/tag so a pinned or stale -- install always downloads the binary built for its exact commit. CI also moves -- the rolling `nightly` tag to HEAD for "give me latest" tooling, but clients -- never resolve to it when their commit has a per-sha tag. return { version = version, release_tag = version, is_release = false, npm_tag = npm_tag, } end return M