Skip to content

fix(arcup): honor SemVer prerelease precedence in version_gt (fixes #205)#212

Open
danilaverbena wants to merge 1 commit into
circlefin:mainfrom
danilaverbena:fix/arcup-semver-prerelease-precedence
Open

fix(arcup): honor SemVer prerelease precedence in version_gt (fixes #205)#212
danilaverbena wants to merge 1 commit into
circlefin:mainfrom
danilaverbena:fix/arcup-semver-prerelease-precedence

Conversation

@danilaverbena

Copy link
Copy Markdown

Problem (fixes #205)

version_gt() strips the prerelease suffix from both operands before comparing:

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

So any prerelease information is discarded and versions that differ only in their prerelease compare as equal:

  • version_gt "0.3.0" "0.3.0-rc.1" returns non-zero → the stable release is not seen as newer than its release candidate.
  • version_gt "0.3.0-rc.2" "0.3.0-rc.1" also returns non-zero → a newer RC is not detected.

Because version_gt drives the installer's self-update checks (arcup/arcup lines ~413-430 and ~454-462), arcup fails to recognise a stable release as an upgrade over a previously installed prerelease.

Fix

Implement SemVer precedence (semver.org §11) in version_gt:

  1. Split each version into its major.minor.patch core and its prerelease, and drop build metadata (+...), which is ignored for precedence.
  2. Compare the core numerically (unchanged behaviour).
  3. When cores are equal, apply prerelease precedence:
    • a version without a prerelease outranks the same version with one;
    • otherwise compare dot-separated identifiers left to right — numeric identifiers compared numerically, numeric ranked lower than alphanumeric, alphanumeric compared in ASCII order, and a larger set of identifiers outranks a smaller one when all preceding identifiers are equal.

Testing

Verified with a local harness covering the reported cases plus the full precedence chain from the SemVer spec (§11.4):

1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta
            < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0

All 18 assertions pass, including the two bug reproductions from the issue, existing core-version comparisons, and build-metadata being ignored. bash -n clean; behaviour for plain major.minor.patch inputs is unchanged.

Scope: single self-contained function in arcup/arcup; no other changes.

version_gt stripped the prerelease suffix from both operands before comparing, so versions differing only in prerelease (e.g. 0.3.0 vs 0.3.0-rc.1) compared as equal and the installer's self-update check failed to see a stable release as newer than a prerelease. Implement SemVer section 11 precedence: compare core numerically, then apply prerelease rules (no-prerelease outranks prerelease; dot-separated identifiers compared numerically/lexically; more identifiers outrank fewer). Fixes circlefin#205.
@osr21

osr21 commented Jul 21, 2026

Copy link
Copy Markdown

Good fix — the bug is real and the implementation is substantially correct. A few observations:

Correctness check

The two reported cases from #205 both work correctly with the new code:

  • version_gt "0.3.0" "0.3.0-rc.1": cores equal, pre1="", pre2="rc.1" → hits [ -z "$pre1" ] && return 0
  • version_gt "0.3.0-rc.2" "0.3.0-rc.1": cores equal, both have prereleases → identifier arrays ["rc","2"] vs ["rc","1"]rc=rc (continue), 2 > 1 numeric → return 0 ✓

The full SemVer §11.4 precedence chain also holds:

Comparison Expected Result
alpha < alpha.1 fewer ids → lower a1 exhausted → return 1 ✓
alpha.1 < alpha.beta numeric < alphanum 1 is numeric, beta is alphanum → return 1 ✓
alpha.beta < beta ASCII order "alpha" < "beta" → return 1 ✓
beta < beta.2 more ids → higher a1 exhausted → return 1 ✓
beta.2 < beta.11 numeric, not lexicographic 2 < 11 integer comparison → return 1 ✓
rc.1 < 1.0.0 stable > any prerelease pre2=""[ -z "$pre2" ] && return 1

One subtle issue: %% vs % on the prerelease strip

local core1="${v1%%-*}" pre1=""

%% removes the longest suffix matching -*, which is correct — for 1.0.0-rc.1+build, the longest -* suffix is -rc.1+build, leaving core1="1.0.0". If % (shortest) had been used instead, it would only strip the last -something segment and produce a wrong core. This is correct as written, just worth calling out explicitly since it's the load-bearing operator choice.

Minor: glob expansion in the array split

local LC_ALL=C IFS=.
local -a a1=($pre1) a2=($pre2)

($pre1) is subject to pathname expansion before word-splitting. If the current working directory happened to contain files matching a prerelease identifier (unlikely for rc, alpha, beta, but not impossible in a dev environment running arcup from a dirty tree), an identifier could expand unexpectedly. Defensive fix:

local LC_ALL=C IFS=. -
set -f
local -a a1=($pre1) a2=($pre2)
set +f

Or equivalently wrap in a subshell with noglob. For Arc version strings in practice this will never trigger, so it's a minor nit rather than a blocking issue.

Build metadata with embedded hyphens (edge case, not a blocker)

For a version string like 1.0.0+build-123 (build metadata containing a hyphen — unusual but valid per spec):

  • "${v1%%-*}" strips the longest -* suffix → core1="1.0.0+build", then %%+*"1.0.0"
  • But "$v1" ≠ "$core1" is true (they differ), so pre1="${v1#*-}""123"
  • After %%+* on pre1: no + present → pre1="123" — incorrectly treated as a prerelease

This only fires when build metadata itself contains a hyphen. Arc release tags (v0.7.3, v0.7.3-rc.1) never produce this pattern, so it has no practical impact. Flagging for completeness.

Test harness

The PR description mentions a local harness with 18 assertions. If there's any path to committing that harness alongside arcup (even as arcup/version_gt_test.sh with a # NOT installed comment), it would let CI or a maintainer reproduce the verification without re-deriving it from the spec. Understandable if the project doesn't have shell test infrastructure today.

Bash dependency

local -a, for (( )), and [[ ]] are bash extensions. The existing function already uses local (not POSIX sh), so this is an established assumption throughout arcup. No regression here, just confirming the script should always be invoked via bash rather than sh.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants