Skip to content
Draft
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
41 changes: 17 additions & 24 deletions .github/workflows/check-version-bump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,40 +22,33 @@ jobs:
FILES=$(git diff --name-only HEAD~1 HEAD)
fi
VERSION_FILES_CHANGED=false
echo "$FILES" | grep -qx 'package.json' && VERSION_FILES_CHANGED=true
echo "$FILES" | grep -qx 'lib/contentstack/version.rb' && VERSION_FILES_CHANGED=true
echo "$FILES" | grep -qx 'CHANGELOG.md' && VERSION_FILES_CHANGED=true
echo "version_files_changed=$VERSION_FILES_CHANGED" >> $GITHUB_OUTPUT
# Only lib/, webpack/, dist/, package.json count as release-affecting; .github/ and test/ do not
# Only lib/ counts as release-affecting; .github/ and spec/ do not
CODE_CHANGED=false
echo "$FILES" | grep -qE '^lib/|^webpack/|^dist/' && CODE_CHANGED=true
echo "$FILES" | grep -qx 'package.json' && CODE_CHANGED=true
echo "$FILES" | grep -qE '^lib/' && CODE_CHANGED=true
echo "code_changed=$CODE_CHANGED" >> $GITHUB_OUTPUT

- name: Skip when only test/docs/.github changed
if: steps.detect.outputs.code_changed != 'true'
run: |
echo "No release-affecting files changed (e.g. only test/docs/.github). Skipping version-bump check."
echo "No release-affecting files changed (e.g. only spec/docs/.github). Skipping version-bump check."
exit 0

- name: Fail when version bump was missed
if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed != 'true'
run: |
echo "::error::This PR has code changes but no version bump. Please bump the version in package.json and add an entry in CHANGELOG.md."
echo "::error::This PR has code changes but no version bump. Please bump the version in lib/contentstack/version.rb and add an entry in CHANGELOG.md."
exit 1

- name: Setup Node
if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed == 'true'
uses: actions/setup-node@v4
with:
node-version: '22.x'

- name: Check version bump
if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed == 'true'
run: |
set -e
PKG_VERSION=$(node -p "require('./package.json').version.replace(/^v/, '')")
if [ -z "$PKG_VERSION" ]; then
echo "::error::Could not read version from package.json"
GEM_VERSION=$(sed -n 's/.*VERSION = "\(.*\)".*/\1/p' lib/contentstack/version.rb)
if [ -z "$GEM_VERSION" ]; then
echo "::error::Could not read version from lib/contentstack/version.rb"
exit 1
fi
git fetch --tags --force 2>/dev/null || true
Expand All @@ -66,21 +59,21 @@ jobs:
fi
LATEST_VERSION="${LATEST_TAG#v}"
LATEST_VERSION="${LATEST_VERSION%%-*}"
if [ "$(printf '%s\n' "$LATEST_VERSION" "$PKG_VERSION" | sort -V | tail -1)" != "$PKG_VERSION" ]; then
echo "::error::Version bump required: package.json version ($PKG_VERSION) is not greater than latest tag ($LATEST_TAG). Please bump the version in package.json."
if [ "$(printf '%s\n' "$LATEST_VERSION" "$GEM_VERSION" | sort -V | tail -1)" != "$GEM_VERSION" ]; then
echo "::error::Version bump required: lib/contentstack/version.rb ($GEM_VERSION) is not greater than latest tag ($LATEST_TAG). Please bump Contentstack::VERSION."
exit 1
fi
if [ "$PKG_VERSION" = "$LATEST_VERSION" ]; then
echo "::error::Version bump required: package.json version ($PKG_VERSION) equals latest tag ($LATEST_TAG). Please bump the version in package.json."
if [ "$GEM_VERSION" = "$LATEST_VERSION" ]; then
echo "::error::Version bump required: lib/contentstack/version.rb ($GEM_VERSION) equals latest tag ($LATEST_TAG). Please bump Contentstack::VERSION."
exit 1
fi
CHANGELOG_VERSION=$(sed -nE 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CHANGELOG.md | head -1)
CHANGELOG_VERSION=$(sed -nE 's/^## Version ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CHANGELOG.md | head -1)
if [ -z "$CHANGELOG_VERSION" ]; then
echo "::error::Could not find a version entry in CHANGELOG.md (expected line like '## [v1.0.0](...)')."
echo "::error::Could not find a version entry in CHANGELOG.md (expected line like '## Version 1.0.0')."
exit 1
fi
if [ "$CHANGELOG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::CHANGELOG version mismatch: CHANGELOG.md top version ($CHANGELOG_VERSION) does not match package.json version ($PKG_VERSION). Please add or update the CHANGELOG entry for $PKG_VERSION."
if [ "$CHANGELOG_VERSION" != "$GEM_VERSION" ]; then
echo "::error::CHANGELOG version mismatch: CHANGELOG.md top version ($CHANGELOG_VERSION) does not match lib/contentstack/version.rb ($GEM_VERSION). Please add or update the CHANGELOG entry for $GEM_VERSION."
exit 1
fi
echo "Version bump check passed: package.json and CHANGELOG.md are at $PKG_VERSION (latest tag: $LATEST_TAG)."
echo "Version bump check passed: lib/contentstack/version.rb and CHANGELOG.md are at $GEM_VERSION (latest tag: $LATEST_TAG)."
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## CHANGELOG

## Version 0.9.0
### Date: 19th-May-2026
### Features
- Added `variants(variant_uids, branch_name)` on `Contentstack::Entry` and `Contentstack::Query` to fetch entry variants with optional per-request branch scoping. Requests send the `x-cs-variant-uid` header (comma-separated UIDs) and respect stack-level or per-call branch.

## Version 0.8.4
### Date: 15th-April-2026
### Security and Compatibility
Expand Down
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
contentstack (0.8.4)
contentstack (0.8.5)
activesupport (>= 3.2)
contentstack_utils (~> 1.2)

Expand Down Expand Up @@ -39,12 +39,12 @@ GEM
hashdiff (1.2.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
json (2.19.4)
json (2.19.5)
logger (1.7.0)
minitest (6.0.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
nokogiri (1.19.2-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
prism (1.9.0)
public_suffix (7.0.5)
Expand Down
56 changes: 48 additions & 8 deletions lib/contentstack/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,51 @@ def self.get_sync_items(query)
fetch_retry(path, query)
end

def self.validate_variant_uids!(variant_uids)
if variant_uids.nil? || (variant_uids.respond_to?(:empty?) && variant_uids.empty?)
raise Contentstack::Error.new("Variant UID(s) are required. Provide a variant UID or an array of variant UIDs.")
end
unless variant_uids.is_a?(String) || variant_uids.is_a?(Array)
raise Contentstack::Error.new("Variant UID(s) must be a String or Array of Strings.")
end
if variant_uids.is_a?(Array) && variant_uids.any? { |uid| !uid.is_a?(String) || uid.empty? }
raise Contentstack::Error.new("Variant UID(s) must be a String or Array of Strings.")
end
end

private
def self.prepare_query(q)
q = (q || {}).dup
variant_uids = q.delete(:variant_uids)
branch_override = q.delete(:branch)
[q, variant_uids, branch_override]
end

def self.format_variant_uids(variant_uids)
return nil if variant_uids.nil?

case variant_uids
when String
variant_uids.strip
when Array
variant_uids.map(&:to_s).reject(&:empty?).join(', ')
end
end

def self.resolve_branch(branch_override)
branch = branch_override
branch = @branch if branch.nil? || branch.to_s.empty?
branch
end

def self.apply_variant_headers(params, variant_uids, branch_override)
formatted = format_variant_uids(variant_uids)
params["x-cs-variant-uid"] = formatted if formatted && !formatted.empty?

branch = resolve_branch(branch_override)
params["branch"] = branch if !branch.nil? && !branch.empty?
params
end
def self.fetch_retry(path, query=nil, count=0)
response = send_request(path, query)
if @errorRetry.include?(response["status_code"].to_i)
Expand All @@ -90,7 +134,7 @@ def self.fetch_retry(path, query=nil, count=0)
end

def self.send_request(path, q=nil)
q ||= {}
q, variant_uids, branch_override = prepare_query(q)

q.merge!(@headers)

Expand All @@ -103,9 +147,7 @@ def self.send_request(path, q=nil)
"x-user-agent" => "ruby-sdk/#{Contentstack::VERSION}",
"read_timeout" => @timeout
}
if !@branch.nil? && !@branch.empty?
params["branch"] = @branch
end
apply_variant_headers(params, variant_uids, branch_override)

if @proxy_details.present? && @proxy_details[:url].present? && @proxy_details[:port].present? && @proxy_details[:username].empty? && @proxy_details[:password].empty?
params["proxy"] = URI.parse("http://#{@proxy_details[:url]}:#{@proxy_details[:port]}/").to_s
Expand All @@ -130,7 +172,7 @@ def self.send_request(path, q=nil)
end

def self.send_preview_request(path, q=nil)
q ||= {}
q, variant_uids, branch_override = prepare_query(q)

q.merge!({live_preview: (!@live_preview.key?(:live_preview) ? 'init' : @live_preview[:live_preview]),})

Expand All @@ -143,9 +185,7 @@ def self.send_preview_request(path, q=nil)
"x-user-agent" => "ruby-sdk/#{Contentstack::VERSION}",
"read_timeout" => @timeout
}
if !@branch.nil? && !@branch.empty?
params["branch"] = @branch
end
apply_variant_headers(params, variant_uids, branch_override)

if @proxy_details.present? && @proxy_details[:url].present? && @proxy_details[:port].present? && @proxy_details[:username].empty? && @proxy_details[:password].empty?
params["proxy"] = URI.parse("http://#{@proxy_details[:url]}:#{@proxy_details[:port]}/").to_s
Expand Down
20 changes: 20 additions & 0 deletions lib/contentstack/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,26 @@ def include_metadata(flag=true)
self
end

# Scope the entry request to one or more entry variants, optionally on a branch.
#
# @param [String, Array<String>] variant_uids A variant UID, or an array of variant UIDs
# @param [String] branch_name Branch name to scope the request (overrides stack-level branch)
#
# Example
#
# @entry = @stack.content_type('home_page').entry(entry_uid)
# @entry.variants('xyz', 'branch_name').fetch
#
# @entry = @stack.content_type('home_page').entry(entry_uid)
# @entry.variants(['variant1', 'variant2'], 'branch_name').fetch
#
# @return [Contentstack::Entry]
def variants(variant_uids, branch_name = nil)
API.validate_variant_uids!(variant_uids)
@query[:variant_uids] = variant_uids
@query[:branch] = branch_name if branch_name.is_a?(String) && !branch_name.empty?
self
end

# Include Embedded Objects (Entries and Assets) along with entry/entries details.
#
Expand Down
21 changes: 21 additions & 0 deletions lib/contentstack/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,27 @@ def include_branch(flag=true)
self
end

# Scope the entries request to one or more entry variants, optionally on a branch.
#
# @param [String, Array<String>] variant_uids A variant UID, or an array of variant UIDs
# @param [String] branch_name Branch name to scope the request (overrides stack-level branch)
#
# Example
#
# @query = @stack.content_type('home_page').query
# @query.variants('xyz', 'branch_name').fetch
#
# @query = @stack.content_type('home_page').query
# @query.variants(['variant1', 'variant2'], 'branch_name').fetch
#
# @return [Contentstack::Query]
def variants(variant_uids, branch_name = nil)
API.validate_variant_uids!(variant_uids)
@query[:variant_uids] = variant_uids
@query[:branch] = branch_name if branch_name.is_a?(String) && !branch_name.empty?
self
end

# Include Embedded Objects (Entries and Assets) along with entry/entries details.
#
# Example
Expand Down
2 changes: 1 addition & 1 deletion lib/contentstack/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Contentstack
VERSION = "0.8.4"
VERSION = "0.9.0"
end
97 changes: 97 additions & 0 deletions spec/variants_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
require 'spec_helper'

describe 'entry variants' do
let(:client) { create_client }
let(:branch_client) { create_client(ENV['DELIVERY_TOKEN'], ENV['API_KEY'], ENV['ENVIRONMENT'], { branch: 'stack_branch' }) }
let(:entry_uid) { 'uid' }
let(:category_entry) { client.content_type('category').entry(entry_uid) }
let(:category_query) { client.content_type('category').query }

describe Contentstack::Entry do
it 'stores variant UID and branch on the query' do
entry = category_entry.variants('variant1', 'branch_name')
expect(entry.query[:variant_uids]).to eq 'variant1'
expect(entry.query[:branch]).to eq 'branch_name'
end

it 'stores multiple variant UIDs' do
entry = category_entry.variants(['variant1', 'variant2'], 'branch_name')
expect(entry.query[:variant_uids]).to eq ['variant1', 'variant2']
end

it 'raises when variant UIDs are missing' do
expect { category_entry.variants(nil) }.to raise_error(Contentstack::Error, /Variant UID/)
expect { category_entry.variants([]) }.to raise_error(Contentstack::Error, /Variant UID/)
end

it 'raises when variant UIDs are invalid' do
expect { category_entry.variants(123) }.to raise_error(Contentstack::Error, /String or Array/)
expect { category_entry.variants(['']) }.to raise_error(Contentstack::Error, /String or Array/)
end
end

describe Contentstack::Query do
it 'stores variant UID and branch on the query' do
query = category_query.variants('variant1', 'branch_name')
expect(query.query[:variant_uids]).to eq 'variant1'
expect(query.query[:branch]).to eq 'branch_name'
end
end

describe 'HTTP headers' do
it 'sends x-cs-variant-uid and branch for a single entry fetch' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries\/uid/).
with { |req|
req.headers['X-Cs-Variant-Uid'] == 'variant1' &&
req.headers['Branch'] == 'branch_name'
}.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry.json'), headers: {})

category_entry.variants('variant1', 'branch_name').fetch
expect(stub).to have_been_requested
end

it 'sends comma-separated variant UIDs for multiple variants' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries\/uid/).
with { |req| req.headers['X-Cs-Variant-Uid'] == 'variant1, variant2' }.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry.json'), headers: {})

category_entry.variants(['variant1', 'variant2'], 'branch_name').fetch
expect(stub).to have_been_requested
end

it 'sends x-cs-variant-uid and branch for an entries query' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries/).
with { |req|
!req.uri.path.include?('/entries/uid') &&
req.headers['X-Cs-Variant-Uid'] == 'variant1' &&
req.headers['Branch'] == 'branch_name'
}.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry_collection_without_count.json'), headers: {})

category_query.variants('variant1', 'branch_name').fetch
expect(stub).to have_been_requested
end

it 'uses stack-level branch when variants branch is omitted' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries\/uid/).
with { |req|
req.headers['X-Cs-Variant-Uid'] == 'variant1' &&
req.headers['Branch'] == 'stack_branch'
}.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry.json'), headers: {})

branch_client.content_type('category').entry(entry_uid).variants('variant1').fetch
expect(stub).to have_been_requested
end

it 'does not add variant headers when variants is not chained' do
stub = stub_request(:get, /cdn\.contentstack\.io\/v3\/content_types\/category\/entries\/uid/).
with { |req| req.headers['X-Cs-Variant-Uid'].nil? }.
to_return(status: 200, body: File.read(File.dirname(__FILE__) + '/fixtures/category_entry.json'), headers: {})

category_entry.fetch
expect(stub).to have_been_requested
end
end
end
Loading