diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ecdbaa..60615d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 https://github.com/beplus/be/compare/main...dev +## [v0.8.0] (09/12/2026) + +Resolve versions by SemVer precedence instead of trusting the order the release index arrives in. +The GitHub Releases API sorts by `created_at`, and every beplus/cli release shares one, so the list +tied and came back unordered — `be 2` could hand out a stage build published before the prod one, +and `be latest` could pick an older major. A release now always outranks the pre-releases it was +promoted from, and pre-release build numbers compare numerically (`beta.11` > `beta.2`). + ## [v0.7.0] (07/03/2026) Make `be latest`/`current`/`stable` install the newest official release, skipping pre-releases (explicit pre-release versions can still be installed) diff --git a/bin/be b/bin/be index 1317b6a..925bd78 100755 --- a/bin/be +++ b/bin/be @@ -64,7 +64,7 @@ function n_grep() { # Setup and state # -VERSION="v0.7.0" +VERSION="v0.8.0" BE_PREFIX="${BE_PREFIX-/usr/local}" BE_PREFIX=${BE_PREFIX%/} @@ -1156,17 +1156,41 @@ function display_remote_versions() { # local index_url="https://beplus.s3.amazonaws.com/cli/releases.json" local index_url="${BE_RELEASE_INDEX_URL:-https://beplus.s3.amazonaws.com/cli/releases.json}" - # Select the release names to consider. GitHub lists the newest release - # first, including any in-progress pre-releases (e.g. a v2 published while v1 - # is still the current release), so 'latest'/'current'/'stable' filter those - # out to resolve to the newest official release. + # Select the release names to consider. 'latest'/'current'/'stable' mean the newest official + # release, so they skip pre-releases and drafts. local jq_release_filter='.[]' if [[ -n "${official_only}" ]]; then jq_release_filter='.[] | select(.prerelease != true and .draft != true)' fi + # ...then order them ourselves, because no index promises to. The GitHub Releases API sorts by + # created_at, and every beplus/cli release shares one (the tags all point at that repo's + # unchanged default-branch HEAD), so the list ties and comes back in no particular order — + # which had `be 2` resolving to a stage build published before the prod one. Callers take the + # first match, so the sort is what makes "first" mean "newest". + # + # SemVer §11 sort key: major, minor, patch, then "has no pre-release" (a release outranks the + # pre-releases it was promoted from), then the pre-release identifiers — numeric ones compared + # numerically and ranked below alphanumeric ones, a longer run of identifiers winning a shared + # prefix. A name that is not a version sorts last rather than aborting the pipeline. + # + # This is NOT `sort -V`: GNU version sort ranks 2.0.0-dev.40 *above* 2.0.0, the opposite of + # SemVer, which is exactly backwards for a promotion pipeline. + local jq_semver_key='def semver_key: + ltrimstr("v") + | if test("^[0-9]+\\.[0-9]+\\.[0-9]+([-+].*)?$") + then (capture("^(?[0-9]+)\\.(?[0-9]+)\\.(?[0-9]+)(?:-(?
[^+]+))?(?:\\+.*)?$")
+              | [1, (.ma|tonumber), (.mi|tonumber), (.pa|tonumber),
+                 (if .pre == null then 1 else 0 end),
+                 (if .pre == null then []
+                  else (.pre | split(".")
+                        | map(if test("^[0-9]+$") then [0, (.|tonumber), ""] else [1, 0, .] end))
+                  end)])
+        else [0, 0, 0, 0, 0, []]
+        end;'
+
   do_get_index "${index_url}" \
-    | jq -r "${jq_release_filter} | .name" \
+    | jq -r "${jq_semver_key} [ ${jq_release_filter} | .name ] | sort_by(semver_key) | reverse | .[]" \
     | n_grep -E "${match}" \
     | awk "NR<=${match_count}" \
     | cut -f 1 \
diff --git a/package.json b/package.json
index a238dab..1249bcc 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@beplus/be",
   "description": "Interactively Manage beplus CLI Versions",
-  "version": "0.7.0",
+  "version": "0.8.0",
   "author": "Igor Lamos ",
   "homepage": "https://github.com/beplus/be",
   "bugs": "https://github.com/beplus/be",
diff --git a/test/tests/ordering.bats b/test/tests/ordering.bats
new file mode 100644
index 0000000..88844ab
--- /dev/null
+++ b/test/tests/ordering.bats
@@ -0,0 +1,84 @@
+#!/usr/bin/env bats
+#
+# Version resolution must not depend on the order the index happens to arrive in.
+#
+# The GitHub Releases API sorts by created_at, and every beplus/cli release shares one — the tags
+# all point at that repo's unchanged default-branch HEAD — so the list ties and comes back in no
+# particular order. `be` used to take the first match, which had `be 2` handing out a stage build
+# published before the prod one. These run against a deliberately scrambled index.
+
+load shared-functions
+load '../../node_modules/bats-support/load'
+load '../../node_modules/bats-assert/load'
+
+
+function setup() {
+  unset_n_env
+  INDEX="${BATS_TEST_TMPDIR:-${BATS_TMPDIR}}/releases.json"
+  cat > "${INDEX}" <<'JSON'
+[
+  {"name": "v2.0.0-dev.29",   "tag_name": "v2.0.0-dev.29",   "prerelease": true,  "draft": false},
+  {"name": "v1.0.5",          "tag_name": "v1.0.5",          "prerelease": false, "draft": false},
+  {"name": "v2.0.0",          "tag_name": "v2.0.0",          "prerelease": false, "draft": false},
+  {"name": "v2.0.0-stage.30", "tag_name": "v2.0.0-stage.30", "prerelease": true,  "draft": false},
+  {"name": "v2.0.0-prod.31",  "tag_name": "v2.0.0-prod.31",  "prerelease": true,  "draft": false},
+  {"name": "v1.0.0-beta.2",   "tag_name": "v1.0.0-beta.2",   "prerelease": true,  "draft": false},
+  {"name": "v1.0.0-beta.11",  "tag_name": "v1.0.0-beta.11",  "prerelease": true,  "draft": false}
+]
+JSON
+  export BE_RELEASE_INDEX_URL="file://${INDEX}"
+}
+
+
+function teardown() {
+  unset BE_RELEASE_INDEX_URL
+}
+
+
+@test "be ls-remote orders by SemVer precedence, not index order" {
+  output="$(be ls-remote)"
+  assert_equal "${output}" "2.0.0
+2.0.0-stage.30
+2.0.0-prod.31
+2.0.0-dev.29
+1.0.5
+1.0.0-beta.11
+1.0.0-beta.2"
+}
+
+@test "a release outranks the pre-releases it was promoted from" {
+  # The reported bug: this used to resolve to whichever v2 the index listed first.
+  output="$(be ls-remote 2)"
+  assert_equal "$(echo "${output}" | head -n 1)" "2.0.0"
+}
+
+@test "pre-release build numbers compare numerically, not as strings" {
+  output="$(be ls-remote 1.0.0)"
+  assert_equal "$(echo "${output}" | head -n 1)" "1.0.0-beta.11"
+}
+
+@test "be latest picks the newest official release, not the first one listed" {
+  output="$(be --latest)"
+  assert_equal "${output}" "2.0.0"
+}
+
+@test "be stable picks the newest official release" {
+  output="$(be --stable)"
+  assert_equal "${output}" "2.0.0"
+}
+
+@test "an exact version still resolves to itself" {
+  output="$(be ls-remote 2.0.0-prod.31)"
+  assert_equal "${output}" "2.0.0-prod.31"
+}
+
+@test "a name that is not a version does not break resolution" {
+  cat > "${INDEX}" <<'JSON'
+[
+  {"name": "nightly", "tag_name": "nightly", "prerelease": true,  "draft": false},
+  {"name": "v2.0.0",  "tag_name": "v2.0.0",  "prerelease": false, "draft": false}
+]
+JSON
+  output="$(be --latest)"
+  assert_equal "${output}" "2.0.0"
+}
diff --git a/test/tests/shared-functions.bash b/test/tests/shared-functions.bash
index 5046940..a3ab396 100644
--- a/test/tests/shared-functions.bash
+++ b/test/tests/shared-functions.bash
@@ -11,6 +11,7 @@ function unset_n_env(){
   unset BE_MIRROR
   unset BE_DOWNLOAD_MIRROR
   unset BE_MAX_REMOTE_MATCHES
+  unset BE_RELEASE_INDEX_URL
   unset HTTP_USER
   unset HTTP_PASSWORD
   unset GREP_OPTIONS