Skip to content

Allow yanking a specific Ruby ABI variant - #862

Open
jenshenny wants to merge 1 commit into
ho/feature-branch-ca-server-changesfrom
jenshenny/yank-ruby-abi-versions
Open

Allow yanking a specific Ruby ABI variant#862
jenshenny wants to merge 1 commit into
ho/feature-branch-ca-server-changesfrom
jenshenny/yank-ruby-abi-versions

Conversation

@jenshenny

@jenshenny jenshenny commented Aug 9, 2026

Copy link
Copy Markdown

rubygems#6674

Problem

Content-addressable gems mean multiple versions can now share the same number and platform — one per Ruby ABI:

sandworm 1.0.0 x86_64-linux-musl  ruby_abi=3.2  → sandworm-1.0.0-724c5206
sandworm 1.0.0 x86_64-linux-musl  ruby_abi=3.4  → sandworm-1.0.0-9f8e7d6c

The deletions API resolves the version to yank via Rubygem#find_version!(number:, platform:), which uses find_by! — so gem yank sandworm -v 1.0.0 --platform x86_64-linux-musl would match both rows and delete whichever the database returned first. A destructive operation should never pick its target arbitrarily.

Solution

find_version! now scopes on ruby_abi, defaulting to nil, and the deletions API accepts an optional ruby_abi param:

Request Result
platform + matching ruby_abi ✅ yanks exactly that variant
platform + ruby_abi matching no variant 404
platform only, when only ABI variants exist 404 — nothing is deleted arbitrarily
platform only, when a version supporting multiple Ruby ABIs coexists ✅ yanks only that version; ABI variants untouched
ruby_abi without platform 400 — a platform is required (ABI gems are always platformed)
empty ruby_abi param treated as absent (normalized with .presence, matching platform's existing handling)

Testing

Push three real .gem files (ABI 3.2 + 3.4 variants and a version supporting multiple Ruby ABIs, all sharing number + platform) through the full push pipeline with the feature flag enabled, then exercises every yank resolution path, asserting HTTP status, response body, and the indexed state of all three versions after each step.

Run from the repo root with the server on :3000 — paste the whole block into your console:

Tophat script (single paste)
bash <<'TOPHAT'
set -uo pipefail

BASE_URL="${BASE_URL:-http://localhost:3000}"
GEM="yanktophat$(date +%s)"
PLATFORM="x86_64-linux-musl"
BUILD_DIR=$(mktemp -d)
PASS=0 FAIL=0

step()  { printf '\n\033[1m== %s\033[0m\n' "$*"; }
ok()    { PASS=$((PASS+1)); printf '  \033[32mPASS\033[0m %s\n' "$*"; }
bad()   { FAIL=$((FAIL+1)); printf '  \033[31mFAIL\033[0m %s\n' "$*"; }

yank() { # yank <desc> <expected_code> <body_substring> -- form args...
  local desc="$1" want_code="$2" want_body="$3"; shift 3; shift # drop --
  local args=() a
  for a in "$@"; do args+=(--data-urlencode "$a"); done
  local resp code body
  resp=$(curl -s -w $'\n%{http_code}' -X DELETE "$BASE_URL/api/v1/gems/yank" \
              -H "Authorization: $KEY" "${args[@]}")
  code=${resp##*$'\n'}
  body=${resp%$'\n'*}
  if [[ "$code" == "$want_code" && "$body" == *"$want_body"* ]]; then
    ok "$desc → $code, body matched"
  else
    bad "$desc → got $code (want $want_code); body: ${body:0:150}"
  fi
}

push() { # push <file> <expected_body_substring>
  local file="$1" want_body="$2" resp code body
  resp=$(curl -s -w $'\n%{http_code}' -X POST "$BASE_URL/api/v1/gems" \
              -H "Authorization: $KEY" -H "Content-Type: application/octet-stream" \
              --data-binary "@$file")
  code=${resp##*$'\n'}
  body=${resp%$'\n'*}
  if [[ "$code" == "200" && "$body" == *"$want_body"* ]]; then
    ok "pushed $(basename "$file") → $code: ${body:0:110}"
  else
    bad "push $(basename "$file") → got $code; body: ${body:0:150}"
  fi
}

state() { # state <desc> <expected "t t t" for abi32 abi34 multi; ? = row absent>
  local desc="$1" want="$2" got
  got=$(bin/rails runner "
    r = Rubygem.find_by!(name: \"$GEM\")
    line = [r.versions.find_by(ruby_abi: \"3.2\"), r.versions.find_by(ruby_abi: \"3.4\"),
            r.versions.find_by(ruby_abi: nil, platform: \"$PLATFORM\")]
             .map { |v| v.nil? ? '?' : (v.indexed ? 't' : 'f') }.join(' ')
    puts \"STATE=#{line}\"" 2>/dev/null | grep "^STATE=" | cut -d= -f2)
  if [[ "$got" == "$want" ]]; then
    ok "$desc (abi32 abi34 multi = $got)"
  else
    bad "$desc — indexed state abi32 abi34 multi = '$got', want '$want'"
  fi
}

step "Preflight: server on :3000"
curl -sf "$BASE_URL/" > /dev/null || { echo "Server not running — start it with ./.agents/skills/run-rubygems-org/smoke.sh"; exit 1; }
ok "server is up"

step "Setup: user + push/yank API key + feature flag"
RUNNER_LOG=$(mktemp)
KEY=$(bin/rails runner "
  user = User.find_or_create_by!(handle: \"yanktophat\") do |u|
    u.email = \"yanktophat@rubygems-test.org\"
    u.password = SecureRandom.hex(16)
    u.email_confirmed = true
  end
  FeatureFlag.enable_for_actor(FeatureFlag::CONTENT_ADDRESSABLE_GEM_PUSHES, user)
  raw = SecureRandom.hex(24)
  user.api_keys.create!(name: \"tophat-#{SecureRandom.hex(4)}\",
                        hashed_key: Digest::SHA256.hexdigest(raw),
                        scopes: %w[push_rubygem yank_rubygem])
  puts \"KEY=#{raw}\"
" 2>"$RUNNER_LOG" | grep "^KEY=" | cut -d= -f2)
if [[ -n "$KEY" ]]; then
  ok "user + key ready, CONTENT_ADDRESSABLE_GEM_PUSHES enabled"
else
  bad "setup failed — bin/rails runner output:"
  tail -15 "$RUNNER_LOG" | sed 's/^/    /'
  echo "    (common causes: wrong ruby active — .ruby-version wants $(cat .ruby-version), you have $(ruby -v 2>/dev/null | cut -d' ' -f2); or pending migrations: bin/rails db:migrate)"
  exit 1
fi

step "Build: three real .gem files (same number + platform)"
ruby -rrubygems/package -e '
  gem_name, platform, build_dir = ARGV
  [["skinny32", "~> 3.2.0"], ["skinny34", "~> 3.4.0"], ["multi", ">= 3.2"]].each do |label, req|
    dir = File.join(build_dir, label)
    Dir.mkdir(dir)
    spec = Gem::Specification.new do |s|
      s.name        = gem_name
      s.version     = "1.0.0"
      s.platform    = platform
      s.summary     = "yank tophat (#{label})"
      s.authors     = ["tophat"]
      s.files       = []
      s.required_ruby_version = req
    end
    Dir.chdir(dir) { Gem::Package.build(spec) }
  end
' "$GEM" "$PLATFORM" "$BUILD_DIR" > /dev/null 2>&1 \
  && ok "built skinny 3.2 / skinny 3.4 / multi-ABI in $BUILD_DIR" \
  || { bad "gem build failed"; exit 1; }

step "Push: skinny variants through the real pipeline"
push "$BUILD_DIR/skinny32/$GEM-1.0.0-$PLATFORM.gem" "Ruby ABI 3.2"
push "$BUILD_DIR/skinny34/$GEM-1.0.0-$PLATFORM.gem" "Ruby ABI 3.4"

ADDRS=$(bin/rails runner "
  r = Rubygem.find_by!(name: \"$GEM\")
  puts \"ADDR32=#{r.versions.find_by!(ruby_abi: \"3.2\").full_name.split(\"-\").last}\"
  puts \"ADDR34=#{r.versions.find_by!(ruby_abi: \"3.4\").full_name.split(\"-\").last}\"
" 2>/dev/null | grep -E "^ADDR3[24]=")
eval "$ADDRS"
[[ -n "${ADDR32:-}" && -n "${ADDR34:-}" ]] && ok "content addresses: $ADDR32 / $ADDR34" || { bad "could not read content addresses"; exit 1; }
state "both variants indexed after push" "t t ?"

step "Sanity: /info shows content-addressed lines with normalized rubygems floor"
INFO=$(curl -s "$BASE_URL/info/$GEM")
[[ "$INFO" == *"1.0.0-$ADDR32 "* && "$INFO" == *"platform:= $PLATFORM"* && "$INFO" == *"rubygems:>= 4.1.0.beta1"* ]] \
  && ok "/info has 1.0.0-$ADDR32, platform:= metadata and rubygems:>= 4.1.0.beta1" \
  || bad "/info unexpected: ${INFO:0:250}"

step "1. ruby_abi matching no variant -> 404, nothing yanked"
yank "yank ruby_abi=3.3" 404 "The version 1.0.0 ($PLATFORM) (Ruby ABI 3.3) does not exist." -- \
  "gem_name=$GEM" "version=1.0.0" "platform=$PLATFORM" "ruby_abi=3.3"
state "both variants still indexed" "t t ?"

step "2. ruby_abi without platform -> 400"
yank "yank ruby_abi=3.2, no platform" 400 "The platform param is required when ruby_abi is specified." -- \
  "gem_name=$GEM" "version=1.0.0" "ruby_abi=3.2"
state "both variants still indexed" "t t ?"

step "3. platform only, when only ABI variants exist -> 404, nothing yanked"
yank "yank platform only" 404 "The version 1.0.0 ($PLATFORM) does not exist." -- \
  "gem_name=$GEM" "version=1.0.0" "platform=$PLATFORM"
state "both variants still indexed" "t t ?"

step "4. push the multi-ABI platform version alongside the variants"
push "$BUILD_DIR/multi/$GEM-1.0.0-$PLATFORM.gem" "Successfully registered gem: $GEM (1.0.0-$PLATFORM)"
state "all three versions indexed" "t t t"

step "5. empty ruby_abi treated as absent -> yanks the multi-ABI platform version"
yank "yank ruby_abi='' + platform" 200 "Successfully deleted gem: $GEM (1.0.0-$PLATFORM)" -- \
  "gem_name=$GEM" "version=1.0.0" "platform=$PLATFORM" "ruby_abi="
state "only the multi-ABI version yanked" "t t f"

step "6. platform only, after the multi-ABI version was yanked -> 422 already deleted"
yank "yank platform only (already deleted)" 422 "has already been deleted" -- \
  "gem_name=$GEM" "version=1.0.0" "platform=$PLATFORM"
state "no change" "t t f"

step "7. targeted ABI yank -> yanks exactly that variant"
yank "yank ruby_abi=3.2" 200 "Successfully deleted gem: $GEM (1.0.0-$ADDR32, Platform: $PLATFORM, Ruby ABI 3.2)" -- \
  "gem_name=$GEM" "version=1.0.0" "platform=$PLATFORM" "ruby_abi=3.2"
state "3.2 yanked, 3.4 untouched" "f t f"

step "8. remaining variant is independently yankable"
yank "yank ruby_abi=3.4" 200 "Successfully deleted gem: $GEM (1.0.0-$ADDR34, Platform: $PLATFORM, Ruby ABI 3.4)" -- \
  "gem_name=$GEM" "version=1.0.0" "platform=$PLATFORM" "ruby_abi=3.4"
state "all three versions yanked" "f f f"

rm -rf "$BUILD_DIR"
printf '\n\033[1m%d passed, %d failed\033[0m\n' "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
TOPHAT
Output (24 passed, 0 failed)
== Preflight: server on :3000
  PASS server is up

== Setup: user + push/yank API key + feature flag
  PASS user + key ready, CONTENT_ADDRESSABLE_GEM_PUSHES enabled

== Build: three real .gem files (same number + platform)
  PASS built skinny 3.2 / skinny 3.4 / multi-ABI

== Push: skinny variants through the real pipeline
  PASS pushed → 200: Successfully registered gem: yanktophat (1.0.0-cf3314b4, Platform: x86_64-linux-musl, Ruby ABI 3.2)
  PASS pushed → 200: Successfully registered gem: yanktophat (1.0.0-208f03e0, Platform: x86_64-linux-musl, Ruby ABI 3.4)
  PASS content addresses: cf3314b4 / 208f03e0
  PASS both variants indexed after push (abi32 abi34 multi = t t ?)

== Sanity: /info shows content-addressed lines with normalized rubygems floor
  PASS /info has 1.0.0-cf3314b4, platform:= metadata and rubygems:>= 4.1.0.beta1

== 1. ruby_abi matching no variant -> 404, nothing yanked
  PASS yank ruby_abi=3.3 → 404, body matched
  PASS both variants still indexed (abi32 abi34 multi = t t ?)

== 2. ruby_abi without platform -> 400
  PASS yank ruby_abi=3.2, no platform → 400, body matched
  PASS both variants still indexed (abi32 abi34 multi = t t ?)

== 3. platform only, when only ABI variants exist -> 404, nothing yanked
  PASS yank platform only → 404, body matched
  PASS both variants still indexed (abi32 abi34 multi = t t ?)

== 4. push the multi-ABI platform version alongside the variants
  PASS pushed → 200: Successfully registered gem: yanktophat (1.0.0-x86_64-linux-musl)
  PASS all three versions indexed (abi32 abi34 multi = t t t)

== 5. empty ruby_abi treated as absent -> yanks the multi-ABI platform version
  PASS yank ruby_abi='' + platform → 200, body matched
  PASS only the multi-ABI version yanked (abi32 abi34 multi = t t f)

== 6. platform only, after the multi-ABI version was yanked -> 422 already deleted
  PASS yank platform only (already deleted) → 422, body matched
  PASS no change (abi32 abi34 multi = t t f)

== 7. targeted ABI yank -> yanks exactly that variant
  PASS yank ruby_abi=3.2 → 200, body matched
  PASS 3.2 yanked, 3.4 untouched (abi32 abi34 multi = f t f)

== 8. remaining variant is independently yankable
  PASS yank ruby_abi=3.4 → 200, body matched
  PASS all three versions yanked (abi32 abi34 multi = f f f)

24 passed, 0 failed

Multiple content-addressable versions can share a number and platform,
one per Ruby ABI, so resolving a deletion by number and platform alone
matched an arbitrary variant. find_version! now scopes on ruby_abi
(nil by default, preserving behavior for all existing versions) and the
deletions API accepts a ruby_abi param to target a variant. Without the
param, yanks against ABI variants return 404 rather than deleting an
arbitrary one.

The gem yank CLI needs a matching option to pass ruby_abi.
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.

1 participant