From 3dc50941d4d34d25012f62ab94856cbd4b256c56 Mon Sep 17 00:00:00 2001 From: bea-claude Date: Sat, 12 Sep 2026 22:14:30 +0200 Subject: [PATCH] Resolve versions by SemVer precedence instead of trusting index order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `display_remote_versions` piped the index straight to grep and callers kept the first match, so resolution depended entirely on the order the index arrived in. Nothing promises that order. 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 arbitrarily. `be 2` was resolving to a stage build published before the prod one, and `be --latest` could pick an older major once two full releases existed. Sort by SemVer §11 before filtering: 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 winning a shared prefix. A name that is not a version sorts last rather than aborting the pipeline. Note this is not `sort -V`: GNU version sort ranks 2.0.0-dev.40 above 2.0.0, which is backwards for a promotion pipeline. The new tests run against a deliberately scrambled index; five of the seven fail without the sort. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +++ bin/be | 36 +++++++++++--- package.json | 2 +- test/tests/ordering.bats | 84 ++++++++++++++++++++++++++++++++ test/tests/shared-functions.bash | 1 + 5 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 test/tests/ordering.bats 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