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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +191 -0
- data/.yardopts +16 -0
- data/CONTRIBUTING.md +131 -0
- data/LICENSE +201 -0
- data/README.md +183 -0
- data/RELEASE.md +313 -0
- data/Rakefile +16 -0
- data/convert_sdk.gemspec +50 -0
- data/lib/convert_sdk/api_manager.rb +288 -0
- data/lib/convert_sdk/background_timer.rb +129 -0
- data/lib/convert_sdk/bucketed_feature.rb +35 -0
- data/lib/convert_sdk/bucketed_variation.rb +43 -0
- data/lib/convert_sdk/bucketing_manager.rb +134 -0
- data/lib/convert_sdk/client.rb +417 -0
- data/lib/convert_sdk/comparisons.rb +257 -0
- data/lib/convert_sdk/config.rb +214 -0
- data/lib/convert_sdk/config_validator.rb +127 -0
- data/lib/convert_sdk/context.rb +618 -0
- data/lib/convert_sdk/data_manager.rb +897 -0
- data/lib/convert_sdk/data_store_manager.rb +185 -0
- data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
- data/lib/convert_sdk/enums/feature_status.rb +13 -0
- data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
- data/lib/convert_sdk/enums/log_level.rb +22 -0
- data/lib/convert_sdk/enums/rule_error.rb +19 -0
- data/lib/convert_sdk/enums/system_events.rb +29 -0
- data/lib/convert_sdk/event_manager.rb +125 -0
- data/lib/convert_sdk/experience_manager.rb +69 -0
- data/lib/convert_sdk/feature_manager.rb +367 -0
- data/lib/convert_sdk/fork_guard.rb +144 -0
- data/lib/convert_sdk/http_client.rb +198 -0
- data/lib/convert_sdk/log_manager.rb +168 -0
- data/lib/convert_sdk/murmur_hash3.rb +129 -0
- data/lib/convert_sdk/redactor.rb +93 -0
- data/lib/convert_sdk/rule_manager.rb +242 -0
- data/lib/convert_sdk/segments_manager.rb +241 -0
- data/lib/convert_sdk/sentinel.rb +57 -0
- data/lib/convert_sdk/stores/memory_store.rb +55 -0
- data/lib/convert_sdk/stores/redis_store.rb +126 -0
- data/lib/convert_sdk/version.rb +14 -0
- data/lib/convert_sdk/visitors_queue.rb +190 -0
- data/lib/convert_sdk.rb +218 -0
- data/scripts/check-generated-rbs-header.sh +41 -0
- data/steep/config_contract_probe.rb +154 -0
- metadata +93 -0
data/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Convert Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://github.com/convertcom/ruby-sdk/actions/workflows/qa.yml)
|
|
4
|
+
[](https://rubygems.org/gems/convert_sdk)
|
|
5
|
+
[](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]
|
data/convert_sdk.gemspec
ADDED
|
@@ -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
|