convert_sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +191 -0
  4. data/.yardopts +16 -0
  5. data/CONTRIBUTING.md +131 -0
  6. data/LICENSE +201 -0
  7. data/README.md +183 -0
  8. data/RELEASE.md +313 -0
  9. data/Rakefile +16 -0
  10. data/convert_sdk.gemspec +50 -0
  11. data/lib/convert_sdk/api_manager.rb +288 -0
  12. data/lib/convert_sdk/background_timer.rb +129 -0
  13. data/lib/convert_sdk/bucketed_feature.rb +35 -0
  14. data/lib/convert_sdk/bucketed_variation.rb +43 -0
  15. data/lib/convert_sdk/bucketing_manager.rb +134 -0
  16. data/lib/convert_sdk/client.rb +417 -0
  17. data/lib/convert_sdk/comparisons.rb +257 -0
  18. data/lib/convert_sdk/config.rb +214 -0
  19. data/lib/convert_sdk/config_validator.rb +127 -0
  20. data/lib/convert_sdk/context.rb +618 -0
  21. data/lib/convert_sdk/data_manager.rb +897 -0
  22. data/lib/convert_sdk/data_store_manager.rb +185 -0
  23. data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
  24. data/lib/convert_sdk/enums/feature_status.rb +13 -0
  25. data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
  26. data/lib/convert_sdk/enums/log_level.rb +22 -0
  27. data/lib/convert_sdk/enums/rule_error.rb +19 -0
  28. data/lib/convert_sdk/enums/system_events.rb +29 -0
  29. data/lib/convert_sdk/event_manager.rb +125 -0
  30. data/lib/convert_sdk/experience_manager.rb +69 -0
  31. data/lib/convert_sdk/feature_manager.rb +367 -0
  32. data/lib/convert_sdk/fork_guard.rb +144 -0
  33. data/lib/convert_sdk/http_client.rb +198 -0
  34. data/lib/convert_sdk/log_manager.rb +168 -0
  35. data/lib/convert_sdk/murmur_hash3.rb +129 -0
  36. data/lib/convert_sdk/redactor.rb +93 -0
  37. data/lib/convert_sdk/rule_manager.rb +242 -0
  38. data/lib/convert_sdk/segments_manager.rb +241 -0
  39. data/lib/convert_sdk/sentinel.rb +57 -0
  40. data/lib/convert_sdk/stores/memory_store.rb +55 -0
  41. data/lib/convert_sdk/stores/redis_store.rb +126 -0
  42. data/lib/convert_sdk/version.rb +14 -0
  43. data/lib/convert_sdk/visitors_queue.rb +190 -0
  44. data/lib/convert_sdk.rb +218 -0
  45. data/scripts/check-generated-rbs-header.sh +41 -0
  46. data/steep/config_contract_probe.rb +154 -0
  47. metadata +93 -0
data/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # Convert Ruby SDK
2
+
3
+ [![Quality Checks](https://github.com/convertcom/ruby-sdk/actions/workflows/qa.yml/badge.svg)](https://github.com/convertcom/ruby-sdk/actions/workflows/qa.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/convert_sdk.svg)](https://rubygems.org/gems/convert_sdk)
5
+ [![API docs](https://img.shields.io/badge/API_docs-YARD-blue)](https://convertcom.github.io/ruby-sdk)
6
+
7
+ The official Convert Experiences FullStack Ruby SDK — server-side A/B testing,
8
+ feature flags, and personalizations for Ruby applications. Bucketing-compatible
9
+ with the Convert JavaScript SDK. **Zero runtime dependencies** (stdlib only).
10
+
11
+ - **Fork-safe with zero configuration** — works under Puma cluster, Unicorn,
12
+ Passenger, Sidekiq, AWS Lambda, and plain CLI scripts. See
13
+ [Fork safety](#fork-safety-zero-config) below — it's the differentiator.
14
+ - **Never crashes the host** — every public method degrades to a documented
15
+ return value and a log line; only misconfiguration at `ConvertSdk.create`
16
+ raises (an `ArgumentError`).
17
+ - **Ruby ≥ 3.1** — CRuby 3.1–3.4 and JRuby are supported.
18
+
19
+ ## Install
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "convert_sdk"
24
+ ```
25
+
26
+ ```sh
27
+ gem install convert_sdk
28
+ ```
29
+
30
+ ## 5-minute start
31
+
32
+ The complete flow — build a client, create a per-visitor context, decide an
33
+ experience, act on the result, track a conversion, and flush — in one
34
+ copy-pasteable block:
35
+
36
+ ```ruby
37
+ require "convert_sdk"
38
+
39
+ # 1. Build ONE client at boot and reuse it for the life of the process.
40
+ # (Fetch mode: pass an sdk_key. Direct-data mode: pass a pre-fetched `data:`.)
41
+ CONVERT_SDK = ConvertSdk.create(sdk_key: ENV.fetch("CONVERT_SDK_KEY"))
42
+
43
+ # 2. One context per visitor (per web request / per job). Cheap — no network,
44
+ # no thread.
45
+ context = CONVERT_SDK.create_context("visitor-123", { "country" => "US" })
46
+
47
+ # 3. Decide an experience. Returns a BucketedVariation on a hit, or a Sentinel
48
+ # on a miss — NEVER raises, NEVER a bare nil.
49
+ variation = context.run_experience("homepage-test")
50
+
51
+ # 4. Act on the result. `variation&.key` is the variation key on a hit and nil
52
+ # on a miss (a Sentinel's #key is always nil), so a single `case` covers both.
53
+ case variation&.key
54
+ when nil then render_default # business miss — show the control
55
+ when "treatment" then render_treatment
56
+ else render_variation(variation.key)
57
+ end
58
+
59
+ # 5. Track a conversion with revenue. Deduplicated per visitor per goal.
60
+ context.track_conversion("purchase", goal_data: { amount: 49.99, transaction_id: "tx-1" })
61
+
62
+ # 6. Flush queued events synchronously. In long-running servers the background
63
+ # timer also drains; call flush explicitly before a process exits (Lambda/CLI).
64
+ CONVERT_SDK.flush
65
+ ```
66
+
67
+ > **Production wiring per runtime** — Rails, Sidekiq, AWS Lambda, and CLI recipes live in the wiki: [Fork Safety & Runtime Recipes](https://github.com/convertcom/ruby-sdk/wiki/ForkSafety) and [Quickstart](https://github.com/convertcom/ruby-sdk/wiki/Quickstart).
68
+
69
+ ## Fork safety (zero config)
70
+
71
+ Fork safety is the SDK's flagship guarantee, so it leads the docs.
72
+
73
+ **The claim:** build the client once, let your server fork workers, and events
74
+ are delivered from every forked worker — **with zero fork-handling code in your
75
+ app.** No `postfork`, no `on_worker_boot` hook required.
76
+
77
+ **How it works:**
78
+
79
+ - At `require "convert_sdk"` the SDK installs a single `Process._fork` hook
80
+ (its only global mutation). The hook is cheap and starts **no threads**.
81
+ - The SDK starts **no background threads until first use** — a client built in a
82
+ preloading master (Puma `preload_app!`) carries no thread state across the
83
+ fork.
84
+ - On the first decision in a forked worker, the `_fork` detection plus
85
+ **PID-guarded** flush boundaries automatically re-arm the client (timers
86
+ re-start lazily, the queue's process ownership resets) — so the worker decides
87
+ and delivers on its own.
88
+
89
+ **When you need `postfork`:** only for setups that bypass `Process._fork`
90
+ entirely (or daemonize via `Process.daemon`), or if you prefer an explicit
91
+ re-arm (LaunchDarkly-style). The
92
+ [fork/daemon matrix in the troubleshooting guide](https://github.com/convertcom/ruby-sdk/wiki/ForkSafety#forkdaemon-matrix)
93
+ spells out exactly which runtimes are automatic and which need an explicit
94
+ `CONVERT_SDK.postfork` call. The four quickstarts ship the right wiring for each.
95
+
96
+ ## Public API
97
+
98
+ The full public API is documented in the wiki and the YARD API reference. The
99
+ entry point is `ConvertSdk.create` (factory); it returns a `Client` with
100
+ `#create_context`, `#flush`, `#postfork`, and `#on`. Each `Context` exposes
101
+ `#run_experience`, `#run_feature`, `#track_conversion`, and related methods.
102
+ See [Code Examples](https://github.com/convertcom/ruby-sdk/wiki/CodeExamples) and
103
+ the **[API reference (YARD)](https://convertcom.github.io/ruby-sdk)** for full
104
+ signatures.
105
+
106
+ ## The sentinel return contract
107
+
108
+ Decisioning methods **never raise** and **never return a bare `nil`** for a
109
+ business miss. They return a value object:
110
+
111
+ - A **hit** returns a frozen `BucketedVariation` (or `BucketedFeature`): `#key`
112
+ is the real key, `#error?` is `false`.
113
+ - A **miss** returns a frozen `Sentinel`: `#key` is **always `nil`**, `#error?`
114
+ is **always `true`**, and `#to_s` is the wire string.
115
+
116
+ This is why the documented branch pattern works for both cases at once:
117
+
118
+ ```ruby
119
+ case (variation = context.run_experience("homepage-test")).key
120
+ when nil then render_default # Sentinel — a business miss
121
+ else render_variation(variation.key)
122
+ end
123
+ ```
124
+
125
+ For features, branch on `#status` instead (a feature miss is a DISABLED
126
+ `BucketedFeature`, never a sentinel):
127
+
128
+ ```ruby
129
+ feature = context.run_feature("new-checkout")
130
+ if feature.status == ConvertSdk::FeatureStatus::ENABLED
131
+ render_new_checkout(feature.variables["headline"])
132
+ else
133
+ render_legacy_checkout
134
+ end
135
+ ```
136
+
137
+ ## Configuration
138
+
139
+ All configuration options are passed as keyword arguments to `ConvertSdk.create`.
140
+ See the [Configuration wiki page](https://github.com/convertcom/ruby-sdk/wiki/Configuration)
141
+ for the full option table with defaults. Pass `data_refresh_interval: nil` and
142
+ `flush_interval: nil` for Lambda/CLI (timer-off mode).
143
+
144
+ ## Data stores
145
+
146
+ Sticky bucketing and goal deduplication persist through a store port (default:
147
+ in-process `MemoryStore`). See
148
+ [Configuration](https://github.com/convertcom/ruby-sdk/wiki/Configuration) for
149
+ the `RedisStore` recipe and custom store duck-typing contract.
150
+
151
+ ## Documentation
152
+
153
+ Full developer documentation lives in the **[Convert Ruby SDK wiki](https://github.com/convertcom/ruby-sdk/wiki)**:
154
+
155
+ - [Quickstart](https://github.com/convertcom/ruby-sdk/wiki/Quickstart) ·
156
+ [Installation](https://github.com/convertcom/ruby-sdk/wiki/Installation) ·
157
+ [Initialization](https://github.com/convertcom/ruby-sdk/wiki/Initialization)
158
+ - [Configuration](https://github.com/convertcom/ruby-sdk/wiki/Configuration) ·
159
+ [Return Types & Sentinels](https://github.com/convertcom/ruby-sdk/wiki/ReturnTypes) ·
160
+ [Code Examples](https://github.com/convertcom/ruby-sdk/wiki/CodeExamples)
161
+ - [Fork Safety & Runtime Recipes](https://github.com/convertcom/ruby-sdk/wiki/ForkSafety) ·
162
+ [Tracking Control](https://github.com/convertcom/ruby-sdk/wiki/TrackingControl) ·
163
+ [Testing](https://github.com/convertcom/ruby-sdk/wiki/Testing)
164
+ - Core concepts & how-to: bucketing algorithm, rule evaluation, running experiences (see the wiki sidebar)
165
+ - **[API reference (YARD)](https://convertcom.github.io/ruby-sdk)** — generated method-level docs
166
+ - **[Contributing](CONTRIBUTING.md)**
167
+
168
+ ## Development
169
+
170
+ ```sh
171
+ bundle install # install dev/test dependencies
172
+ bundle exec rake # the default task: RSpec + RuboCop
173
+ bundle exec rbs -r net-http -r uri -r json -I sig validate # validate RBS signatures
174
+ bundle exec steep check # static type check
175
+ ```
176
+
177
+ Publishing is handled exclusively by the OIDC release workflow — there is no
178
+ `rake release` task. See [CONTRIBUTING.md](CONTRIBUTING.md) for the release
179
+ process.
180
+
181
+ ## License
182
+
183
+ Apache-2.0. See [LICENSE](LICENSE).
data/RELEASE.md ADDED
@@ -0,0 +1,313 @@
1
+ # Release Process
2
+
3
+ This document describes how releases of the Convert Ruby SDK (`convert_sdk`) are
4
+ produced and what must be configured before the release pipeline can run.
5
+
6
+ The short version: **every push to `main` whose Conventional Commit history
7
+ contains a `feat:`, `fix:`, `refactor:`, or a `BREAKING CHANGE` triggers a new
8
+ release.** The release workflow runs `semantic-release`, which writes the next
9
+ version into `lib/convert_sdk/version.rb` (a build-time, **uncommitted**
10
+ working-tree edit), builds and pushes the gem to RubyGems.org via **OIDC Trusted
11
+ Publishing**, then creates the `vX.Y.Z` git tag and a GitHub Release with the
12
+ generated notes.
13
+
14
+ No manual version bumping. No manual publishing. No long-lived RubyGems API key.
15
+ Conventional commits drive everything, and there is **no `rake release` task** —
16
+ publishing happens only through the OIDC `release.yml` workflow.
17
+
18
+ ---
19
+
20
+ ## Release Chain Overview
21
+
22
+ ```
23
+ PR merged to main (squash merge → PR title becomes the commit subject)
24
+ -> "Quality Checks" workflow runs (lint, typecheck, matrix RSpec, parity,
25
+ full-chain, gem-smoke)
26
+ -> "Release" workflow triggers via workflow_run AFTER Quality Checks succeeds
27
+ (and only when the triggering event was a push to main)
28
+ -> rubygems/configure-rubygems-credentials exchanges the workflow's OIDC
29
+ token for a short-lived RubyGems credential
30
+ -> semantic-release analyzes commits since the last v* tag:
31
+ 1. @semantic-release/commit-analyzer → compute next version
32
+ 2. @semantic-release/release-notes-generator → render markdown notes
33
+ 3. @semantic-release/exec (prepareCmd) → write version into
34
+ lib/convert_sdk/version.rb
35
+ (UNCOMMITTED working-tree edit)
36
+ 4. @semantic-release/exec (publishCmd) → gem build + gem push
37
+ to RubyGems.org
38
+ 5. @semantic-release/github → push vX.Y.Z tag + create
39
+ GitHub Release (via API)
40
+ ```
41
+
42
+ The pipeline is **tag-only** — it pushes **no commit** to `main`. semantic-release
43
+ core pushes only the `vX.Y.Z` tag (a `refs/tags/*` ref, which the `main` branch
44
+ ruleset does not gate), and `@semantic-release/github` creates the Release via
45
+ the GitHub API. The version write in `lib/convert_sdk/version.rb` is a build-time
46
+ working-tree edit consumed by `gem build` and is **never committed**; the next
47
+ release derives its version from this run's git tag.
48
+
49
+ The plugin order above is **load-bearing** (defined in `release.config.mjs`).
50
+ **Publish-before-Release:** step 4 (`gem push`) runs before step 5 (the GitHub
51
+ Release). If `gem push` fails, semantic-release aborts **before** the tag and
52
+ Release are finalized — there is never a GitHub Release without a corresponding
53
+ gem on RubyGems.org. The repo stays in its pre-release state and the next push
54
+ retries.
55
+
56
+ This release flow uses no `@semantic-release/git` and no
57
+ `@semantic-release/changelog` plugins (deliberately forbidden — they would commit
58
+ to `main` and ship a committed `CHANGELOG.md`). The changelog lives on **GitHub
59
+ Releases** (the gemspec's `changelog_uri` points there).
60
+
61
+ ---
62
+
63
+ ## Versioning & Conventional-Commit Map
64
+
65
+ semantic-release computes the next version with the standard
66
+ `@semantic-release/commit-analyzer` (`conventionalcommits` preset). Only the
67
+ following commit types influence a release; everything else is a no-release:
68
+
69
+ | Commit type | Release type | In release notes |
70
+ |---|---|---|
71
+ | `fix:` | patch | Yes (Bug Fixes) |
72
+ | `feat:` | minor | Yes (Features) |
73
+ | `refactor:` | (per preset) | Yes (Refactoring) |
74
+ | `BREAKING CHANGE:` footer / `!` marker | **major** | Yes |
75
+ | `chore:`, `docs:`, `ci:`, `test:`, `style:`, `perf:` | no release | No (hidden) |
76
+
77
+ The release-notes generator surfaces only `feat` / `fix` / `refactor` sections;
78
+ the maintenance types (`chore`, `docs`, `ci`, `test`, `style`, `perf`) are marked
79
+ hidden in `release.config.mjs` and never appear in the notes.
80
+
81
+ All tags use the `v` prefix (`v1.0.0`, `v1.2.3`) — `tagFormat: 'v${version}'`.
82
+
83
+ ---
84
+
85
+ ## One-Time Setup (Repo Admin)
86
+
87
+ These steps must be completed **before the first merge to `main`** that should
88
+ publish, otherwise the release workflow will fail.
89
+
90
+ ### 1. Register the RubyGems Trusted Publisher (OIDC)
91
+
92
+ RubyGems OIDC Trusted Publishing lets the release workflow authenticate with a
93
+ short-lived, exchanged credential instead of a long-lived API key. Register the
94
+ trusted publisher on RubyGems.org once:
95
+
96
+ 1. Sign in at <https://rubygems.org>.
97
+ 2. Create (or claim) the gem `convert_sdk` if it does not exist yet (the first
98
+ `gem push` can also create it once trusted publishing is wired — but the
99
+ trusted-publisher entry must exist first).
100
+ 3. Go to the gem's settings → **Trusted publishers** → **Add a new publisher**
101
+ (GitHub Actions), and enter:
102
+ - **Repository:** `convertcom/ruby-sdk`
103
+ - **Workflow filename:** `release.yml`
104
+ - (Optional) environment: leave blank — the workflow uses no GitHub
105
+ Environment.
106
+ 4. Save. From then on, the `release.yml` workflow running on
107
+ `convertcom/ruby-sdk` is trusted to publish `convert_sdk` with no stored API
108
+ key.
109
+
110
+ There is **no `RUBYGEMS_API_KEY` secret anywhere** — the
111
+ `rubygems/configure-rubygems-credentials@v2.0.0` step in `release.yml` performs
112
+ the OIDC token exchange at run time. This is the Ruby-specific divergence from
113
+ the PHP SDK (whose `release.yml` carries only `contents: write`); ours also needs
114
+ `id-token: write`.
115
+
116
+ ### 2. Enable GitHub Pages (API docs)
117
+
118
+ The YARD API docs deploy to GitHub Pages on every push to `main`
119
+ (`.github/workflows/pages.yml`). Enable Pages once:
120
+
121
+ 1. Repo → **Settings** → **Pages**.
122
+ 2. Set **Source** to **GitHub Actions**.
123
+
124
+ The published docs site is `https://convertcom.github.io/ruby-sdk` (the gemspec's
125
+ `documentation_uri`).
126
+
127
+ ### 3. Repository secrets
128
+
129
+ | Secret | Required | Source |
130
+ |---|---|---|
131
+ | `GITHUB_TOKEN` | yes (auto) | Provided automatically by GitHub Actions for every run — nothing to configure. Used by semantic-release core to push the `vX.Y.Z` tag and by `@semantic-release/github` to create the Release. |
132
+
133
+ That is the **complete** secret list. RubyGems authentication is handled by OIDC
134
+ Trusted Publishing (step 1), so no RubyGems API-key secret is stored. The
135
+ workflow's only declared permissions are `contents: write` (tag + Release) and
136
+ `id-token: write` (OIDC exchange).
137
+
138
+ ### 4. Branch-protection required checks
139
+
140
+ Configure branch protection on `main` (Repo → **Settings** → **Branches** →
141
+ add/edit the `main` rule) to require these status checks to pass before merge.
142
+ The names below are the **exact job names** from the workflows — quote them
143
+ verbatim:
144
+
145
+ From the **Quality Checks** workflow (`.github/workflows/qa.yml`):
146
+
147
+ - `PR title (Conventional Commits)`
148
+ - `Lint (RuboCop)`
149
+ - `Typecheck (RBS + Steep)`
150
+ - `Test (3.1)`, `Test (3.2)`, `Test (3.3)`, `Test (3.4)`, `Test (jruby)`
151
+ - `Cross-SDK parity (MurmurHash3)` — **release-blocking** (100% of the vendored
152
+ MurmurHash3 vectors must pass)
153
+ - `Full-chain release gate` — **release-blocking** (the end-to-end
154
+ create→decide→track→flush loop, exact wire bytes, zero secret leakage)
155
+ - `Gem build / install / require smoke`
156
+
157
+ From the **Demo Fork Smoke** workflow (`.github/workflows/demo-smoke.yml`, a
158
+ structurally independent workflow):
159
+
160
+ - `Puma-cluster fork smoke (release-blocking)` — **release-blocking** (events
161
+ from ≥ 2 distinct forked Puma workers reach the track endpoint)
162
+
163
+ The two release-blocking gates and the fork-smoke gate must be in the required
164
+ set so a hashing/wiring/fork-safety regression can never reach a published gem.
165
+
166
+ ---
167
+
168
+ ## Triggering a Release
169
+
170
+ Releases are fully automatic. The process:
171
+
172
+ 1. Open a PR containing one or more conventional commits. This repo is
173
+ **squash-merge only**, so the **PR title** becomes the squash commit subject
174
+ and must itself be a valid Conventional Commit (CI validates it).
175
+ 2. Merge the PR to `main`. GitHub fires the **Quality Checks** workflow
176
+ (`.github/workflows/qa.yml`).
177
+ 3. On Quality Checks success, GitHub fires the **Release** workflow
178
+ (`.github/workflows/release.yml`) via a `workflow_run` trigger
179
+ (`workflows: ['Quality Checks']`, `branches: [main]`).
180
+ 4. semantic-release analyzes every commit on `main` since the last `v*` tag and
181
+ applies the version/notes map above.
182
+ 5. If a release-worthy commit exists, it writes the version into
183
+ `lib/convert_sdk/version.rb`, runs `gem build convert_sdk.gemspec && gem push
184
+ convert_sdk-*.gem`, then pushes the `vX.Y.Z` tag and creates the GitHub
185
+ Release. If nothing is release-worthy (only `chore`/`docs`/`ci`/`test`/…),
186
+ the workflow succeeds silently with no release.
187
+
188
+ **No manual publish step.** You never bump the version or run `gem push` by hand
189
+ — write an accurate PR title and the pipeline does the rest on merge.
190
+
191
+ ---
192
+
193
+ ## Previewing a Release: Dry Run
194
+
195
+ `yarn release:dry-run` runs semantic-release in dry-run mode
196
+ (`semantic-release --dry-run --no-ci`). It will:
197
+
198
+ - Analyze commits since the last tag.
199
+ - Decide the next version.
200
+ - Show the rendered release notes.
201
+ - **Not** write `version.rb`, **not** build or push the gem, **not** tag.
202
+
203
+ semantic-release checks the current branch against the `branches` entry in
204
+ `release.config.mjs` (currently `['main']`). On `main`, the dry-run prints the
205
+ next-version plan. On any other branch it exits with:
206
+
207
+ ```
208
+ This test run was not triggered in a known release branch
209
+ ```
210
+
211
+ That message is **expected** — it confirms the config parses. To exercise a full
212
+ dry-run on a feature branch, temporarily add the branch name to
213
+ `release.config.mjs`'s `branches` array, run the dry-run, then discard the
214
+ temporary edit before committing:
215
+
216
+ ```bash
217
+ # On main:
218
+ yarn release:dry-run
219
+
220
+ # On a feature branch (full dry-run):
221
+ # 1. Edit release.config.mjs → branches: ['main', 'feature/my-branch']
222
+ # 2. yarn release:dry-run
223
+ # 3. discard the temporary edit to release.config.mjs (do NOT commit it)
224
+ ```
225
+
226
+ The branch must exist on `origin` (semantic-release needs `git ls-remote`); push
227
+ first if it is local-only.
228
+
229
+ ---
230
+
231
+ ## First Release (v1.0.0)
232
+
233
+ The first release is produced automatically by the pipeline — no manual tagging.
234
+ On the first merge to `main` after the release workflow is configured,
235
+ semantic-release observes that no prior `v*` tag exists, so it:
236
+
237
+ 1. Treats every releasable commit in history (all `feat:` / `fix:` / `refactor:`
238
+ / `BREAKING CHANGE` since project inception) as part of the first release.
239
+ 2. Emits `v1.0.0` as the version (semantic-release's fixed first-release
240
+ default).
241
+ 3. Generates a release-notes block covering the full history, grouped by commit
242
+ type.
243
+ 4. Writes `1.0.0` into `lib/convert_sdk/version.rb` (uncommitted), runs `gem
244
+ build` + `gem push`, then pushes the `v1.0.0` tag and publishes a GitHub
245
+ Release on it.
246
+
247
+ `lib/convert_sdk/version.rb` ships with `VERSION = "0.0.0"` as a dev placeholder
248
+ — the first release overwrites it at build time (and never commits the change).
249
+ Do **not** create a `v1.0.0` tag manually before or after the first merge — the
250
+ pipeline owns this, and a pre-existing tag will be raced or block the automated
251
+ tag push.
252
+
253
+ ---
254
+
255
+ ## Fork-PR Safeguard (DO NOT REMOVE)
256
+
257
+ The release workflow's `if:` guard carries two conditions, both required:
258
+
259
+ ```yaml
260
+ if: >
261
+ github.event.workflow_run.conclusion == 'success' &&
262
+ github.event.workflow_run.event == 'push'
263
+ ```
264
+
265
+ The **second** condition — `github.event.workflow_run.event == 'push'` — is
266
+ critical. `workflow_run` fires on every completed Quality Checks run, including
267
+ runs triggered by pull requests. Fork PRs run with no secret/OIDC access, so
268
+ without this guard a fork PR's Quality Checks run would also fire the release
269
+ workflow, which would either:
270
+
271
+ 1. Fail noisily (no OIDC token to exchange), cluttering the PR with red
272
+ cross-marks, or — worse —
273
+ 2. Under certain misconfigurations, leak into the PR's logs.
274
+
275
+ Always keep the `push` check. If you are ever tempted to remove it because
276
+ "release ran twice for one push", the answer is almost certainly a different fix
277
+ (concurrency groups — the workflow already uses `concurrency: { group: release,
278
+ cancel-in-progress: false }`), not weakening this guard.
279
+
280
+ ---
281
+
282
+ ## Rollback Procedure
283
+
284
+ **Published RubyGems versions cannot be silently replaced.** Once `convert_sdk
285
+ X.Y.Z` is pushed, re-pushing the same version is rejected. If a bad release slips
286
+ through:
287
+
288
+ 1. Do **not** try to overwrite the version.
289
+ 2. Push a conventional `fix:` commit that addresses the problem. The next release
290
+ workflow publishes a new patch version (e.g. if `v1.2.3` was bad, the fix
291
+ ships as `v1.2.4`).
292
+ 3. If the bad version must be made un-installable, **yank** it:
293
+ ```bash
294
+ gem yank convert_sdk -v X.Y.Z
295
+ ```
296
+ `gem yank` removes the version from the index so it can no longer be resolved
297
+ by `gem install` / `bundle install`, but it does **not** delete the artifact
298
+ and the version number can never be reused. Prefer shipping a forward fix
299
+ (`fix:`) over yanking unless the release is actively harmful.
300
+
301
+ ---
302
+
303
+ ## Troubleshooting
304
+
305
+ | Symptom | Likely cause | Fix |
306
+ |---|---|---|
307
+ | `release.yml` didn't run after a merge to `main` | Quality Checks failed, or the triggering event wasn't a push, or the commits were all non-release types. | Check the Actions tab — the Release workflow only proceeds when Quality Checks concluded `success` AND the event was `push`. If Quality Checks failed, fix that. If the commits were `chore:`/`docs:`, no release is expected. |
308
+ | Release ran but published nothing | No release-worthy commit since the last tag (only `chore`/`docs`/`ci`/`test`/`style`/`perf`). | Expected — semantic-release succeeds silently with no version. Land a `feat:`/`fix:` to publish. |
309
+ | `gem push` failed / no RubyGems credential | The RubyGems Trusted Publisher is not registered (or the repo/workflow filename in the registration doesn't match `convertcom/ruby-sdk` ↔ `release.yml`). | Re-check the trusted-publisher entry on rubygems.org (One-Time Setup step 1). The workflow needs `id-token: write` (it has it) and the OIDC exchange step must run before semantic-release. |
310
+ | GitHub Release/tag created but gem missing | Should not happen — publish runs before the Release (publish-before-Release). If you see it, a manual tag was likely pushed out of band. | Do not hand-create `v*` tags. Let the pipeline own tagging. |
311
+ | `yarn release:dry-run` errors "This test run was not triggered in a known release branch" | Expected on any branch except `main`. | To force a full dry-run on a feature branch, temporarily add the branch to `release.config.mjs`'s `branches` array (discard before committing). On `main`, this means the local branch isn't pushed to `origin` — push first. |
312
+ | `Cannot find module '<preset>'` from a semantic-release plugin | The yarn node linker isn't producing a `node_modules/` tree the dynamic preset import can walk. | Confirm `.yarnrc.yml` selects the `node-modules` linker and re-run `yarn install --immutable`. |
313
+ | Forbidden release mechanism reintroduced (lint job fails) | A `@semantic-release/git`/`@semantic-release/changelog` plugin, a `rake release` task, `bundler/gem_tasks`, or `rubygems/release-gem` was added. | These are blocked by the release-safety step in the `Lint (RuboCop)` job. Remove the forbidden mechanism — publishing happens only via OIDC `release.yml`. |
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # NOTE: `bundler/gem_tasks` is deliberately NOT required. It defines `rake
4
+ # release`, whose `git_push` pushes the branch ref (the Android qs-03 GH013
5
+ # failure mode). Publishing happens exclusively via the OIDC release workflow
6
+ # (Epic 5) — `rake release` MUST NOT exist in this gem.
7
+
8
+ require "rspec/core/rake_task"
9
+
10
+ RSpec::Core::RakeTask.new(:spec)
11
+
12
+ require "rubocop/rake_task"
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ task default: %i[spec rubocop]
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/convert_sdk/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "convert_sdk"
7
+ spec.version = ConvertSdk::VERSION
8
+ spec.authors = ["Convert Insights, Inc."]
9
+ spec.email = ["support@convert.com"]
10
+
11
+ spec.summary = "Convert Experiences FullStack Ruby SDK for A/B testing, feature flags, and personalizations."
12
+ spec.description = "The official Convert Experiences Ruby SDK. Provides bucketing-compatible " \
13
+ "A/B testing, feature flag evaluation, and personalizations for server-side Ruby " \
14
+ "applications (Rails, Sinatra, Hanami, and plain scripts). Zero runtime dependencies."
15
+ spec.homepage = "https://www.convert.com"
16
+ spec.license = "Apache-2.0"
17
+ spec.required_ruby_version = ">= 3.1.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/convertcom/ruby-sdk"
21
+ spec.metadata["changelog_uri"] = "https://github.com/convertcom/ruby-sdk/releases"
22
+ spec.metadata["documentation_uri"] = "https://github.com/convertcom/ruby-sdk/wiki"
23
+ spec.metadata["rubygems_mfa_required"] = "true"
24
+
25
+ # Specify which files should be added to the gem when it is released.
26
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
27
+ #
28
+ # The reject list keeps non-runtime files OUT of the packaged gem. The Node
29
+ # release tooling (package.json / release.config.mjs / lockfiles / .releaserc /
30
+ # node_modules) is DEV-ONLY: it drives semantic-release on the CI runner and
31
+ # must NOT ship inside the gem — the gem keeps ZERO runtime deps and contains
32
+ # no Node artifacts. (node_modules is also gitignored, so it won't appear in
33
+ # `git ls-files`, but it is listed defensively.)
34
+ spec.files = Dir.chdir(__dir__) do
35
+ `git ls-files -z`.split("\x0").reject do |f|
36
+ (File.expand_path(f) == __FILE__) ||
37
+ f.start_with?(*%w[
38
+ bin/ test/ spec/ features/ docs/ demo/ sig/
39
+ .git .github appveyor Gemfile Steepfile
40
+ package.json package-lock.json yarn.lock .yarnrc.yml .yarn/
41
+ release.config.mjs .releaserc .npmrc node_modules/
42
+ ])
43
+ end
44
+ end
45
+ spec.require_paths = ["lib"]
46
+
47
+ # Runtime dependencies are EMPTY by design (zero-runtime-deps architecture —
48
+ # stdlib only at runtime). Adding a runtime dependency is an architecture
49
+ # change, not a story decision. All dev/test gems live in the Gemfile.
50
+ end