Skip to content

Bug: arcup SemVer comparison treats prerelease and stable versions as equal #205

Description

@mssystem1

Summary

The version_gt() function removes the prerelease suffix from both versions before comparing them:

ver1="${ver1%%-*}"
ver2="${ver2%%-*}"

This causes prerelease and stable versions with the same major, minor, and patch numbers to be treated as equal.

For example:

0.3.0-rc.1
0.3.0

Both are normalized internally to:

0.3.0

As a result, arcup does not recognize the stable release as newer than the release candidate.

Repository

circlefin/arc-node

Create issue:

https://github.com/circlefin/arc-node/issues/new

Affected file

arcup/arcup

arc-node/arcup/arcup

Lines 261 to 286 in 745ba4e

version_gt() {
[ "$1" = "$2" ] && return 1
# Remove 'v' prefix if present
local ver1="${1#v}"
local ver2="${2#v}"
ver1="${ver1%%-*}"
ver2="${ver2%%-*}"
IFS=. read -r major1 minor1 patch1 <<EOF
$ver1
EOF
IFS=. read -r major2 minor2 patch2 <<EOF
$ver2
EOF
[ "$major1" -gt "$major2" ] && return 0
[ "$major1" -lt "$major2" ] && return 1
[ "$minor1" -gt "$minor2" ] && return 0
[ "$minor1" -lt "$minor2" ] && return 1
[ "$patch1" -gt "$patch2" ] && return 0
[ "$patch1" -lt "$patch2" ] && return 1
return 1
}

The function is used by the installer update checks:

arc-node/arcup/arcup

Lines 413 to 430 in 745ba4e

local response
local remote_version
response=$(curl_with_headers -fsSL "$ARCUP_BIN_URL" 2>/dev/null || true)
remote_version=$(printf '%s\n' "$response" | awk -F'"' '/^ARCUP_INSTALLER_VERSION=/ {print $2; exit}')
if [[ -z "$remote_version" ]]; then
return
fi
if version_gt "$remote_version" "$ARCUP_INSTALLER_VERSION"; then
echo ""
warn "Your arcup version ($ARCUP_INSTALLER_VERSION) is outdated."
warn "Latest version is $remote_version."
warn "Run 'arcup --self-update' to update to the latest version."
echo ""
fi
}

arc-node/arcup/arcup

Lines 454 to 462 in 745ba4e

exit 0
fi
info "Updating from $ARCUP_INSTALLER_VERSION to $remote_version..."
if cp "$_ARCUP_TMP_FILE" "$ARCUP_BIN_PATH" 2>/dev/null; then
chmod 755 "$ARCUP_BIN_PATH"
else
error "Failed to update arcup at '$ARCUP_BIN_PATH' — ensure it is writable"
fi

Steps to reproduce

Source the script without running the main installation flow:

export ARCUP_SKIP_MAIN=1
source ./arcup/arcup

Compare a stable version with a release candidate:

version_gt "0.3.0" "0.3.0-rc.1"
echo $?

The function returns a non-zero status, meaning 0.3.0 is not considered greater.

The same issue can be demonstrated in the opposite direction:

version_gt "0.3.0-rc.2" "0.3.0-rc.1"
echo $?

Both prereleases are reduced to 0.3.0, so the newer release candidate is also not detected.

Expected behavior

The comparison should follow Semantic Versioning precedence:

0.3.0-rc.1 < 0.3.0-rc.2 < 0.3.0

Therefore:

version_gt "0.3.0" "0.3.0-rc.1"

should return success.

Likewise:

version_gt "0.3.0-rc.2" "0.3.0-rc.1"

should return success.

Actual behavior

All versions sharing the same major, minor, and patch numbers are treated as equal after their prerelease suffixes are removed.

Examples incorrectly treated as equivalent:

1.0.0-alpha
1.0.0-beta
1.0.0-rc.1
1.0.0

Impact

Users running a prerelease version of arcup may not be notified about or upgraded to the corresponding stable release.

Users may also fail to receive newer prerelease builds when only the prerelease identifier changes.

This can leave users on:

  • release-candidate code after a stable release is available;
  • older prerelease builds;
  • installer versions containing bugs already fixed in a later prerelease or stable version.

Root cause

The implementation compares only numeric core components:

major.minor.patch

and intentionally discards everything following -, even though the accepted version format explicitly allows prerelease suffixes:

^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.+-]+)?$

The accepted input format and comparison behavior are therefore inconsistent.

Suggested fix

Use a SemVer-aware comparison implementation.

At minimum:

  1. Compare major, minor, and patch numerically.
  2. When the core versions are equal:
    • a stable version is greater than a prerelease;
    • compare dot-separated prerelease identifiers;
    • compare numeric identifiers numerically;
    • compare non-numeric identifiers lexically;
    • numeric identifiers have lower precedence than non-numeric identifiers.

Alternatively, use a small dedicated SemVer utility already available on the target platforms or restrict installer versions to stable releases only.

Example expected comparisons:

1.0.0 > 1.0.0-rc.1
1.0.0-rc.2 > 1.0.0-rc.1
1.0.0-beta.11 > 1.0.0-beta.2
1.0.1 > 1.0.0
2.0.0 > 1.99.99

Tests

Add tests for:

version_gt "1.0.0" "1.0.0-rc.1"
version_gt "1.0.0-rc.2" "1.0.0-rc.1"
version_gt "1.0.0-beta.11" "1.0.0-beta.2"
version_gt "1.0.1" "1.0.0"
version_gt "2.0.0" "1.99.99"

Also verify the inverse cases return false:

version_gt "1.0.0-rc.1" "1.0.0"
version_gt "1.0.0-rc.1" "1.0.0-rc.2"
version_gt "1.0.0" "1.0.0"

Checklist

  • Preserve prerelease identifiers during comparison
  • Treat stable releases as newer than matching prereleases
  • Compare prerelease identifiers according to SemVer
  • Add unit tests for prerelease ordering
  • Verify update notifications from RC to stable
  • Verify self-update from older to newer prereleases

Activity

  1. osr21 commented on Jul 18, 2026

    @osr21

    The analysis is correct. Here is a tested drop-in replacement for version_gt that covers all the cases in the issue.

    Replacement version_gt

    # Compare SemVer versions per semver.org 2.0.0
    # Returns 0 if $1 > $2, 1 otherwise (equal or less)
    version_gt() {
      local ver1="${1#v}" ver2="${2#v}"
    
      # Split core (major.minor.patch) and prerelease
      local core1="${ver1%%-*}" pre1=""
      local core2="${ver2%%-*}" pre2=""
      [[ "$ver1" == *-* ]] && pre1="${ver1#*-}"
      [[ "$ver2" == *-* ]] && pre2="${ver2#*-}"
    
      IFS='.' read -r maj1 min1 pat1 <<< "$core1"
      IFS='.' read -r maj2 min2 pat2 <<< "$core2"
    
      # Compare numeric core fields
      if   (( maj1 != maj2 )); then (( maj1 > maj2 )) && return 0 || return 1
      elif (( min1 != min2 )); then (( min1 > min2 )) && return 0 || return 1
      elif (( pat1 != pat2 )); then (( pat1 > pat2 )) && return 0 || return 1
      fi
    
      # Cores are equal — prerelease precedence per §11
      [[ -z "$pre1" && -n "$pre2" ]] && return 0   # stable > any pre
      [[ -n "$pre1" && -z "$pre2" ]] && return 1   # pre < stable
      [[ -z "$pre1" && -z "$pre2" ]] && return 1   # equal
    
      # Both have prereleases — compare dot-separated identifiers per §11.4
      IFS='.' read -ra ids1 <<< "$pre1"
      IFS='.' read -ra ids2 <<< "$pre2"
      local n1=${#ids1[@]} n2=${#ids2[@]}
      local len=$(( n1 < n2 ? n1 : n2 ))
    
      for (( i=0; i<len; i++ )); do
        local a="${ids1[$i]}" b="${ids2[$i]}"
        if [[ "$a" =~ ^[0-9]+$ && "$b" =~ ^[0-9]+$ ]]; then
          (( a != b )) && { (( a > b )) && return 0 || return 1; }
        elif [[ "$a" =~ ^[0-9]+$ ]]; then
          return 1   # numeric < alphanumeric per §11.4.1.3
        elif [[ "$b" =~ ^[0-9]+$ ]]; then
          return 0   # alphanumeric > numeric
        else
          [[ "$a" != "$b" ]] && { [[ "$a" > "$b" ]] && return 0 || return 1; }
        fi
      done
    
      # All shared fields equal; more fields = greater per §11.4.4
      (( n1 > n2 )) && return 0
      (( n1 < n2 )) && return 1
      return 1
    }

    Test results

    All cases from the issue, plus additional edge cases, verified locally:

    # Reporter's expected-true cases
    version_gt "1.0.0"          "1.0.0-rc.1"    → PASS (true)
    version_gt "1.0.0-rc.2"     "1.0.0-rc.1"    → PASS (true)
    version_gt "1.0.0-beta.11"  "1.0.0-beta.2"  → PASS (true, numeric not lex)
    version_gt "1.0.1"          "1.0.0"         → PASS (true)
    version_gt "2.0.0"          "1.99.99"       → PASS (true)
    version_gt "0.3.0"          "0.3.0-rc.1"    → PASS (true)
    
    # Reporter's expected-false cases
    version_gt "1.0.0-rc.1"     "1.0.0"         → PASS (false)
    version_gt "1.0.0-rc.1"     "1.0.0-rc.2"    → PASS (false)
    version_gt "1.0.0"          "1.0.0"         → PASS (false)
    
    # Additional edge cases
    version_gt "1.0.0-alpha.1"  "1.0.0-alpha"   → PASS (true, more fields > fewer)
    version_gt "1.0.0-rc.1"     "1.0.0-beta.11" → PASS (true, "rc" > "beta" lexically)
    version_gt "1.0.0-alpha"    "1.0.0-1"       → PASS (true, alphanumeric > numeric)
    version_gt "1.0.0-1"        "1.0.0-alpha"   → PASS (false, numeric < alphanumeric)
    version_gt "v1.0.0"         "v0.9.9"        → PASS (true, v-prefix stripped)
    version_gt "1.0.0-rc.1"     "1.0.0-rc.1"   → PASS (false, identical prerelease)
    version_gt "1.0.0-beta.2"   "1.0.0-beta.11" → PASS (false, 2 < 11 numerically)
    

    Notes

    No external dependencies. sort -V (GNU coreutils version sort) handles this on Linux but is absent on macOS (BSD sort has no -V). Since arcup targets both platforms, pure bash is the right approach.

    Bash requirement. The replacement uses [[ ]], (( )), =~, and <<< — all bash-specific. The existing script already requires bash (e.g. line 294 uses [[ ! "$version" =~ ^v[0-9]+... ]]), so this is not a regression from the current POSIX-style IFS=. read <<EOF here-document pattern.

    check_installer_up_to_date and update_arcup (lines 423 and 452) both call version_gt without changes — the replacement is a drop-in.

    SemVer §11 reference: https://semver.org/#spec-item-11

  2. added a commit that references this issue on Jul 20, 2026
    8d3863b
  3. JspIIV commented on Sep 15, 2026

    @JspIIV

    Requesting assignment per the new contribution policy. #300 addresses this: keeps the pre-release tags instead of stripping them, orders two pre-releases per SemVer §11.4, strips build metadata first, and bumps ARCUP_INSTALLER_VERSION so self-update can deliver it. Tests walk the semver.org §11.4 chain and all fail on main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingopsComponent: ops

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions