Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 30 additions & 6 deletions bin/be
Original file line number Diff line number Diff line change
Expand Up @@ -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%/}
Expand Down Expand Up @@ -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("^(?<ma>[0-9]+)\\.(?<mi>[0-9]+)\\.(?<pa>[0-9]+)(?:-(?<pre>[^+]+))?(?:\\+.*)?$")
| [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 \
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@beplus/be",
"description": "Interactively Manage beplus CLI Versions",
"version": "0.7.0",
"version": "0.8.0",
"author": "Igor Lamos <igor@be.plus>",
"homepage": "https://github.com/beplus/be",
"bugs": "https://github.com/beplus/be",
Expand Down
84 changes: 84 additions & 0 deletions test/tests/ordering.bats
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions test/tests/shared-functions.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down