bundle_update 0.0.1 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff9cacf9bd094dae3d26c13b3b405c2913b45015f89e1637851ff4e69465ece1
4
- data.tar.gz: 709fa2001e10aa9b047c352bd4bc8e4c0f661c47ead65e2cd001bc2d270a7286
3
+ metadata.gz: dca4dd6ff6a6b352adc75bb532667af67aaaa9351ff97450195d01834c197508
4
+ data.tar.gz: cdb977853a4317e77999c161db056e1d074690d9a433f0e55bbc4cc8ab0224fb
5
5
  SHA512:
6
- metadata.gz: cbb064521095197336c0702a80e473a57e6eeddb722cd0b358414f2b2a79ca9bffd14a13dc7c12607ef841cde7a80b91b658792e47365e6e31b49d8fda1cfc86
7
- data.tar.gz: 899139a2be9bdf5cf7699c2296aee23f0f990382321ec2efb16c25c5a74a61102db2a7f389817a3afb72108ad10a6351241ee93f5cf6f9903a86a76d4bd5e424
6
+ metadata.gz: '097523c9cfac9ac26557a5cb159d307095f2ab9bb0f84cc92b50974a1ab1e47cc0d3dbe24f42319a4376de98a6952f1a0945aaf09eac93259ad3c97f54ff7e99'
7
+ data.tar.gz: 4b53e1c6e5a80f18417270f97b2d4dfd0ebec1b75af72fc017e37ae2b67176f71eb7ce270934a0204b80392a4ce00b25056c52d3027973775b23c240fb005192
data/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-07-13
11
+
12
+ ### Added
13
+
14
+ - `bundle_update` CLI executable: wraps `bundle update`, forwarding all
15
+ arguments verbatim and preserving bundler's exit code exactly.
16
+ Subcommands `report`, `auth KEY`, `status`, and `version`, with a `--`
17
+ separator to force passthrough when a client gem is literally named
18
+ `report` or similar.
19
+ - Bundler plugin mode (`bundle plugin install bundle_update`): the same
20
+ reporting pipeline runs automatically on `bundle install`/`bundle
21
+ update`, no CLI wrapper required.
22
+ - Free tier (no API key): a lockfile diff classified into
23
+ major/minor/patch/other severities, printed as a terminal summary and
24
+ written as a local markdown report under `bundle_update_reports/`.
25
+ Works fully offline.
26
+ - Paid tier (API key configured): POSTs both lockfiles to
27
+ bundleupdate.com and writes back the hosted changelog report — the
28
+ rich version on a synchronous cache hit, a link to poll later
29
+ (`bundle_update report`) on a cache miss. Any API failure — timeout,
30
+ connection error, malformed response, unexpected status — falls back
31
+ to the local report with exactly one warning line; never blocks or
32
+ fails the underlying `bundle update`.
33
+ - Layered configuration: built-in defaults →
34
+ `~/.config/bundle_update/config.yml` → `./.bundle_update.yml` →
35
+ environment variables (`BUNDLE_UPDATE_API_KEY`, `BUNDLE_UPDATE_API_URL`,
36
+ `BUNDLE_UPDATE_DISABLED`, `BUNDLE_UPDATE_DEBUG`). API keys are always
37
+ masked wherever they might be echoed or logged.
38
+ - Zero runtime dependencies — stdlib only (`net/http`, `json`, `yaml`,
39
+ `zlib`, `fileutils`, `optparse`, `openssl`).
40
+
41
+ [Unreleased]: https://github.com/deadbro-com/bundle_update/compare/v0.1.0...HEAD
42
+ [0.1.0]: https://github.com/deadbro-com/bundle_update/releases/tag/v0.1.0
data/DECISIONS.md ADDED
@@ -0,0 +1,129 @@
1
+ # Decisions
2
+
3
+ Ambiguities encountered while building this gem, and what was decided.
4
+ Investigated against the actual installed Bundler source
5
+ (`bundler-4.0.13`, satisfies `>= 2.3`) rather than guessed.
6
+
7
+ ## Bundler plugin hook names
8
+
9
+ Bundler defines these plugin events in `lib/bundler/plugin/events.rb`:
10
+
11
+ | Constant | String | Fired with | Fired from |
12
+ |---|---|---|---|
13
+ | `GEM_BEFORE_INSTALL_ALL` | `"before-install-all"` | `definition.dependencies` | `Bundler::Installer.install`, before `installer.run` |
14
+ | `GEM_AFTER_INSTALL_ALL` | `"after-install-all"` | `definition.dependencies` | `Bundler::Installer.install`, after `installer.run` |
15
+ | `GEM_BEFORE_INSTALL` / `GEM_AFTER_INSTALL` | per-gem | a `SpecInstallation` | `ParallelInstaller#install_gem_from_spec` |
16
+ | `GEM_BEFORE_REQUIRE_ALL` / `GEM_AFTER_REQUIRE_ALL` | `"before-require-all"` / `"after-require-all"` | `dependencies` | `Runtime#require` |
17
+
18
+ We use `before-install-all` / `after-install-all`. The per-gem hooks pass a
19
+ `SpecInstallation` (one gem, install-time metadata) — not a lockfile
20
+ snapshot, and firing once per gem is the wrong granularity for a
21
+ whole-lockfile diff. The require-all hooks fire on `require`, not
22
+ `install`/`update`, so they wouldn't fire at all for `bundle update
23
+ --no-install`-style flows and fire at the wrong time (require, not
24
+ install) otherwise.
25
+
26
+ ## Both `bundle install` and `bundle update` fire the same hooks
27
+
28
+ `Bundler::Installer.install` is the single method both `cli/install.rb`
29
+ and `cli/update.rb` call to do the actual install work — `bundle update`
30
+ just builds a `Definition` that unlocks specific gems before calling it.
31
+ Both hooks fire for a plain `bundle install` too. Decision: don't special
32
+ case this in the plugin — `Runner#report` already no-ops silently on an
33
+ empty diff, so a plain install (or a re-run with nothing to update) is a
34
+ no-op through the normal fail-open path, not a separate code path.
35
+
36
+ ## Lockfile write timing relative to `after-install-all`
37
+
38
+ Traced `Installer.install`:
39
+
40
+ ```ruby
41
+ def self.install(root, definition, options = {})
42
+ installer = new(root, definition)
43
+ Plugin.hook(Plugin::Events::GEM_BEFORE_INSTALL_ALL, definition.dependencies)
44
+ installer.run(options) # <- calls @definition.lock internally (installer.rb:232), which writes the file
45
+ Plugin.hook(Plugin::Events::GEM_AFTER_INSTALL_ALL, definition.dependencies)
46
+ installer
47
+ end
48
+ ```
49
+
50
+ On Bundler 4.0.13, the lockfile **is** already rewritten to disk by the
51
+ time `after-install-all` fires, because `Installer#run` calls
52
+ `@definition.lock` (which calls `write_lock`) before returning. This
53
+ ordering isn't part of the documented plugin contract, though, and the
54
+ task brief specifically flagged it as a known cross-version caveat — so
55
+ we don't rely on it. Decision: in the `after-install-all` hook, prefer
56
+ `Bundler.definition.to_lock` (`LockfileGenerator.generate(self)` — a pure
57
+ computation from the already-resolved in-memory `Definition`, so it's
58
+ correct regardless of whether the file has been flushed yet), and fall
59
+ back to re-reading the file if that raises. An `at_exit` handler is
60
+ registered as a last-resort second attempt, guarded so it never
61
+ double-reports if the primary hook already succeeded.
62
+
63
+ ## Hook registration API and compatibility
64
+
65
+ `Bundler::Plugin.add_hook(event, &block)` and `Bundler::Plugin.hook(event,
66
+ *args)` are `module_function`s on `Bundler::Plugin` (`plugin.rb`).
67
+ `PLUGIN_FILE_NAME = "plugins.rb"` — confirmed constant, loaded from the
68
+ gem root via `Kernel#load(path, true)` (wrapped in an anonymous module,
69
+ so top-level `require`s in `plugins.rb` still populate global constants
70
+ normally — this is the standard pattern used by real-world Bundler
71
+ plugins).
72
+
73
+ Decision: `BundleUpdate::Plugin.install!` checks
74
+ `Bundler::Plugin.respond_to?(:add_hook)` and
75
+ `Bundler::Plugin::Events.defined_event?(name)` for both events before
76
+ registering anything, and the whole method is wrapped in `rescue
77
+ StandardError`. If a future or ancient Bundler renames/removes this API,
78
+ `install!` silently returns `false` instead of raising — `plugins.rb`
79
+ itself also wraps the `require`+`install!` call in a `begin/rescue`, so a
80
+ plugin bug can never break the host's `bundle install`/`update`.
81
+
82
+ ## `--` separator for the subcommand/gem-name collision
83
+
84
+ A client could have a gem literally named `report`, `auth`, `status`, or
85
+ `version` and want `bundle_update report` to mean "update the gem named
86
+ report", not run our `report` subcommand. Decision: `bundle_update --
87
+ report` forwards everything after `--` to `bundle update` verbatim,
88
+ bypassing subcommand detection entirely (`bundle_update -- --conservative
89
+ report` → `bundle update --conservative report`). Without `--`, a
90
+ recognized subcommand name in the first position is always treated as
91
+ ours — this matches the common CLI convention (git, npm, etc.) and keeps
92
+ the common case (no collision) simple.
93
+
94
+ ## API key masking format
95
+
96
+ The brief showed `bu_live_a1b2…` as an example. Decision: reveal at most
97
+ `min(length - 4, 12)` leading characters, then `…`; keys of 4 characters
98
+ or fewer reveal nothing (`…`). This mirrors how Stripe/GitHub-style
99
+ tokens are usually masked — enough to recognize which key you're looking
100
+ at, never enough to reconstruct it.
101
+
102
+ ## `bundle_update status` API reachability check
103
+
104
+ The brief asks for "API reachability" in `status` output. Decision: this
105
+ is a bare TCP/TLS connect to the configured API host (`Net::HTTP#start`
106
+ with a 2s timeout), not a call against the `/api/v1/reports` contract —
107
+ reachability shouldn't depend on report-endpoint semantics, and we
108
+ didn't want to invent an undocumented health-check endpoint.
109
+
110
+ ## Report filename collisions within the same second
111
+
112
+ `ReportWriter` originally wrote `<timestamp>_report.md` with
113
+ second-granularity timestamps. Two runs completing in the same second (a
114
+ path-sourced gem update runs in well under a second) collided. The first
115
+ fix (append a bare `-2` suffix on collision) was itself broken: `-2` (
116
+ `-` = 0x2D) sorts *before* the plain filename (`_` = 0x5F) in ASCII, so
117
+ `Dir.glob(...).last` could return the *older* report. Fixed by always
118
+ writing a zero-padded sequence (`<timestamp>_01_report.md`,
119
+ `<timestamp>_02_report.md`, ...), which sorts correctly in write order.
120
+ Caught by the real end-to-end integration spec, not a unit test — worth
121
+ noting since it's exactly the kind of ordering bug that only shows up
122
+ against a real, fast filesystem round-trip.
123
+
124
+ ## Zero runtime dependencies, dev deps in the Gemfile
125
+
126
+ `bundle_update.gemspec` declares zero runtime dependencies. Development
127
+ dependencies (rspec, webmock, rubocop, rake) live in the `Gemfile`, not
128
+ the gemspec (`Gemspec/DevelopmentDependencies` — the modern Bundler
129
+ convention), so `gem install bundle_update` never resolves them.
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem "bundler", ">= 2.3"
9
+ gem "rake", "~> 13.0"
10
+ gem "rspec", "~> 3.13"
11
+ gem "rubocop", "~> 1.65"
12
+ gem "webmock", "~> 3.23"
13
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emanuel Comsa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,3 +1,234 @@
1
1
  # bundle_update
2
2
 
3
- Placeholder gem to reserve the name. No functionality.
3
+ `bundle_update` wraps `bundle update` / `bundle install`, diffs your
4
+ `Gemfile.lock` before and after, and tells you what changed —
5
+ major/minor/patch counts and a per-gem table, printed to your terminal and
6
+ saved as markdown. With a [bundleupdate.com](https://www.bundleupdate.com)
7
+ API key it also fetches a full changelog report for every updated gem.
8
+
9
+ Works completely offline with no API key. Zero runtime dependencies.
10
+ Never blocks, slows down, or fails your `bundle update` — see
11
+ [Fail-open guarantee](#fail-open-guarantee).
12
+
13
+ ## 30-second quickstart (CLI mode)
14
+
15
+ ```bash
16
+ gem install bundle_update
17
+ ```
18
+
19
+ Then use `bundle_update` exactly where you'd use `bundle update`:
20
+
21
+ ```bash
22
+ bundle_update # instead of: bundle update
23
+ bundle_update sidekiq --strict # instead of: bundle update sidekiq --strict
24
+ ```
25
+
26
+ That's it. On success, you'll see a summary like:
27
+
28
+ ```
29
+ 1 major, 1 minor, 1 patch updates
30
+
31
+ Major
32
+ rack 2.2.6.4 → 3.0.8
33
+ Minor
34
+ rake 13.0.6 → 13.1.0
35
+ Patch
36
+ rexml 3.2.5 → 3.2.6
37
+
38
+ Report written to bundle_update_reports/20260713120000_01_report.md
39
+ ```
40
+
41
+ and a markdown file under `bundle_update_reports/`. If `bundle update`
42
+ fails, `bundle_update` prints nothing extra and exits with bundler's exact
43
+ exit code — nothing about your workflow changes.
44
+
45
+ Add an API key (see [Free vs. paid tier](#free-vs-paid-tier)) and you'll
46
+ also get a hosted changelog report:
47
+
48
+ ```bash
49
+ bundle_update auth bu_live_your_key_here
50
+ ```
51
+
52
+ ## Plugin mode (zero-effort)
53
+
54
+ Skip the `bundle_update` wrapper entirely and have the report generate
55
+ automatically on every `bundle install`/`bundle update`:
56
+
57
+ ```bash
58
+ bundle plugin install bundle_update
59
+ ```
60
+
61
+ From then on, plain `bundle update` (run by you, a script, or a
62
+ teammate who's never heard of this gem) triggers the same reporting
63
+ pipeline. A plain `bundle install` with no lockfile changes stays
64
+ silent — nothing is printed and no report is written.
65
+
66
+ Plugin mode and the CLI wrapper can be used together or independently;
67
+ they share the same configuration and the same `bundle_update_reports/`
68
+ output directory.
69
+
70
+ ## Free vs. paid tier
71
+
72
+ | | Free (no API key) | Paid (API key configured) |
73
+ |---|---|---|
74
+ | Works offline | Yes | No (falls back to free tier on any network issue) |
75
+ | Version diff, severity classification | Yes | Yes |
76
+ | Terminal summary | Yes | Yes |
77
+ | Local markdown report | Yes | Yes, replaced by the hosted report when available |
78
+ | Per-gem changelog / release notes | No | Yes |
79
+ | Security advisory notes | No | Yes |
80
+ | Hosted, shareable report URL | No | Yes |
81
+
82
+ The server pre-warms changelog data for every gem in your `Gemfile` via
83
+ an hourly crawler, so the API call is expected to be fast (typically
84
+ under 3 seconds) and synchronous. On a cache miss, you get a link to poll
85
+ later with `bundle_update report`.
86
+
87
+ ### Sample: free tier report (`bundle_update_reports/*.md`)
88
+
89
+ ```markdown
90
+ # bundle_update report — my-app
91
+
92
+ _2026-07-13 12:00 +0000_
93
+
94
+ ## Summary
95
+
96
+ - 1 major, 1 minor, 1 patch updates
97
+ - 1 gem added
98
+
99
+ ## Security
100
+
101
+ _Security advisory checks aren't available in the free tier. [Get a
102
+ bundleupdate.com API key](https://www.bundleupdate.com) for CVE-aware
103
+ changelog reports._
104
+
105
+ ## Changes
106
+
107
+ ### Major
108
+
109
+ | Gem | From | To |
110
+ |---|---|---|
111
+ | rack | 2.2.6.4 | 3.0.8 |
112
+
113
+ ### Minor
114
+
115
+ | Gem | From | To |
116
+ |---|---|---|
117
+ | rake | 13.0.6 | 13.1.0 |
118
+ ```
119
+
120
+ ### Sample: paid tier report (cache miss, 202)
121
+
122
+ ```markdown
123
+ # bundle_update report — my-app
124
+ ...(same local summary as above)...
125
+
126
+ ---
127
+
128
+ **Full changelog report generating:** https://www.bundleupdate.com/r/abc123
129
+
130
+ Run `bundle_update report` to fetch the complete version once it's ready.
131
+ ```
132
+
133
+ On a cache hit (200), the file is replaced entirely by the rich markdown
134
+ bundleupdate.com returns — full changelogs, breaking-change notes, and
135
+ security advisories per gem.
136
+
137
+ ## Configuration reference
138
+
139
+ Each layer overrides the one before it: built-in defaults →
140
+ `~/.config/bundle_update/config.yml` → `./.bundle_update.yml`
141
+ (per-project — handy for agencies running per-client keys) → environment
142
+ variables.
143
+
144
+ | Key (YAML) | Env var | Default | Notes |
145
+ |---|---|---|---|
146
+ | `api_key` | `BUNDLE_UPDATE_API_KEY` | _(none)_ | Never put this in your `Gemfile`. `bundle_update auth KEY` writes it to the user-level config file. |
147
+ | `api_url` | `BUNDLE_UPDATE_API_URL` | `http://bundleupdate.test` _(temporary — will be `https://bundleupdate.com` at launch)_ | Override for a staging/dev backend. |
148
+ | `project_name` | _(none)_ | git remote basename, else directory name | Shown as the report title. |
149
+ | `git_remote_url` | _(none)_ | normalized git remote (`host/path`), else _(none)_ | Identifies the project on bundleupdate.com — required, so a repo with no git remote configured can't submit a report. Survives directory renames and `project_name` changes. |
150
+ | `reports_dir` | _(none)_ | `bundle_update_reports` | Where markdown reports are written. |
151
+ | `disabled` | `BUNDLE_UPDATE_DISABLED` | `false` | Set to skip the reporting pipeline entirely — bundler behavior is completely unaffected either way. |
152
+ | `debug` | `BUNDLE_UPDATE_DEBUG` | `false` | See [Troubleshooting](#troubleshooting). |
153
+
154
+ ```yaml
155
+ # .bundle_update.yml (per project) or ~/.config/bundle_update/config.yml
156
+ api_key: bu_live_...
157
+ project_name: my-app
158
+ ```
159
+
160
+ ## Fail-open guarantee
161
+
162
+ `bundle_update` is designed to never be the reason your `bundle update`
163
+ is slow, noisy, or broken:
164
+
165
+ - The entire reporting pipeline runs after bundler has already finished
166
+ and exited successfully — it never delays or blocks the bundler
167
+ process itself.
168
+ - A `bundle update`/`bundle install` failure is passed straight through:
169
+ exact exit code, no extra output, no reporting attempted.
170
+ - If an API key is configured and the API call fails for any reason
171
+ (timeout, connection error, bad response, unexpected status), you get
172
+ **exactly one** short warning line and a local report — never a crash,
173
+ never a hang.
174
+ - If something goes wrong in the local pipeline itself (a bug in this
175
+ gem, a permissions error, anything), it prints nothing and exits
176
+ cleanly. Your bundle run is unaffected either way.
177
+ - In plugin mode, every hook body is wrapped so an exception here can
178
+ never propagate into your `bundle install`/`update`.
179
+
180
+ ## Privacy
181
+
182
+ When an API key is configured, `bundle_update` sends exactly this to
183
+ `POST https://bundleupdate.com/api/v1/reports` (gzipped JSON,
184
+ `Authorization: Bearer <key>`) — nothing else:
185
+
186
+ ```json
187
+ {
188
+ "project": "my-app",
189
+ "lockfile_before": "<raw Gemfile.lock content before the update>",
190
+ "lockfile_after": "<raw Gemfile.lock content after the update>",
191
+ "gem_version": "0.1.0",
192
+ "ran_at": "2026-07-13T12:00:00Z"
193
+ }
194
+ ```
195
+
196
+ No environment variables, no other file contents, no git history, no
197
+ Ruby/OS version scanning beyond what's already in the lockfile itself.
198
+ Without an API key configured, `bundle_update` makes no network requests
199
+ at all.
200
+
201
+ ## Troubleshooting
202
+
203
+ - **Nothing happens after `bundle update`.** That's expected when nothing
204
+ changed in `Gemfile.lock` (e.g. a plain `bundle install`, or an update
205
+ that resolved to the same versions) — no report is written, by design.
206
+ - **A report never shows up even though gems clearly changed.** Set
207
+ `BUNDLE_UPDATE_DEBUG=1` and re-run. Full backtraces are written to
208
+ `.bundle_update/debug.log` (never to your terminal) instead of being
209
+ swallowed silently.
210
+ - **Paid tier keeps falling back to the free tier.** Run `bundle_update
211
+ status` to check your key and API reachability. Check
212
+ `.bundle_update/debug.log` (with `BUNDLE_UPDATE_DEBUG=1`) for the
213
+ underlying network error.
214
+ - **I don't want this running at all, temporarily.**
215
+ `BUNDLE_UPDATE_DISABLED=1 bundle_update` (CLI mode), or set it in your
216
+ shell profile / CI env to cover plugin mode too.
217
+ - **A client gem is literally named `report`, `auth`, `status`, or
218
+ `version`.** Use `bundle_update -- report` — everything after `--` is
219
+ forwarded to `bundle update` verbatim, bypassing subcommand detection.
220
+
221
+ ## Development
222
+
223
+ ```bash
224
+ bundle install
225
+ bundle exec rspec
226
+ bundle exec rubocop
227
+ ```
228
+
229
+ See [DECISIONS.md](DECISIONS.md) for the Bundler internals this gem's
230
+ plugin mode relies on, and why.
231
+
232
+ ## License
233
+
234
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bundle_update/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "bundle_update"
7
+ spec.version = BundleUpdate::VERSION
8
+ spec.authors = ["Emanuel Comsa"]
9
+ spec.email = ["office@rubydev.ro"]
10
+
11
+ spec.summary = "Reports what changed after `bundle update` — free local summaries, optional hosted changelog reports."
12
+ spec.description = <<~DESC
13
+ bundle_update wraps `bundle update` / `bundle install`, diffs your Gemfile.lock
14
+ before and after, and prints a concise summary of what changed (major/minor/patch).
15
+ With a bundleupdate.com API key it also fetches a rich changelog report for every
16
+ updated gem. Works fully offline with no API key. Zero runtime dependencies.
17
+ DESC
18
+ spec.homepage = "https://www.bundleupdate.com"
19
+ spec.license = "MIT"
20
+
21
+ spec.required_ruby_version = ">= 3.0.0"
22
+
23
+ spec.metadata["homepage_uri"] = spec.homepage
24
+ spec.metadata["source_code_uri"] = "https://github.com/deadbro-com/bundle_update"
25
+ spec.metadata["changelog_uri"] = "https://github.com/deadbro-com/bundle_update/blob/main/CHANGELOG.md"
26
+ spec.metadata["bug_tracker_uri"] = "https://github.com/deadbro-com/bundle_update/issues"
27
+ spec.metadata["rubygems_mfa_required"] = "true"
28
+
29
+ gemspec_dir = __dir__
30
+ spec.files = Dir.chdir(gemspec_dir) do
31
+ Dir.glob("**/*", File::FNM_DOTMATCH).select { |f| File.file?(f) }.reject do |f|
32
+ f.start_with?(".git/", "spec/", "sig/", ".github/") ||
33
+ f.match?(/\A(?:\.gitignore|\.rspec|\.rubocop\.yml|Gemfile\.lock|Rakefile)\z/) ||
34
+ f.end_with?(".gem")
35
+ end
36
+ end
37
+ spec.bindir = "exe"
38
+ spec.executables = ["bundle_update"]
39
+ spec.require_paths = ["lib"]
40
+
41
+ # Zero runtime dependencies by design — only stdlib (net/http, json, yaml,
42
+ # zlib, fileutils, optparse). Bundler is a host-provided dependency, not
43
+ # a gem dependency: it is always present wherever this gem runs. Dev
44
+ # dependencies live in the Gemfile, not here.
45
+ end
data/exe/bundle_update ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundle_update"
5
+ require "bundle_update/cli"
6
+
7
+ exit(BundleUpdate::CLI.start(ARGV))
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "json"
7
+ require "zlib"
8
+ require "stringio"
9
+ require "time"
10
+
11
+ module BundleUpdate
12
+ # Talks to the bundleupdate.com API over Net::HTTP with zero gem
13
+ # dependencies. Never raises: every failure mode (timeout, connection
14
+ # refused, malformed JSON, HTTP error status) comes back as a Response with
15
+ # status: :error so callers can fail open without a rescue of their own.
16
+ class ApiClient
17
+ Response = Struct.new(:status, :report_id, :url, :markdown, :error, :error_detail, keyword_init: true) do
18
+ def ready?
19
+ status == :ready
20
+ end
21
+
22
+ def pending?
23
+ status == :pending
24
+ end
25
+
26
+ def error?
27
+ status == :error
28
+ end
29
+ end
30
+
31
+ OPEN_TIMEOUT = 2
32
+ READ_TIMEOUT = 8
33
+
34
+ RETRYABLE_ERRORS = [
35
+ Errno::ECONNREFUSED,
36
+ Errno::ECONNRESET,
37
+ Errno::ETIMEDOUT,
38
+ Errno::EHOSTUNREACH,
39
+ SocketError,
40
+ Net::OpenTimeout,
41
+ Net::ReadTimeout,
42
+ IOError,
43
+ OpenSSL::SSL::SSLError
44
+ ].freeze
45
+
46
+ def initialize(config)
47
+ @config = config
48
+ end
49
+
50
+ # POST /api/v1/reports — expects 200 (cache hit, synchronous markdown),
51
+ # 202 (cache miss, generating async), 401 (bad key), or 422 (validation).
52
+ def create_report(lockfile_before:, lockfile_after:, ran_at:)
53
+ payload = {
54
+ project: @config.project_name,
55
+ git_remote_url: @config.git_remote_url,
56
+ lockfile_before: lockfile_before,
57
+ lockfile_after: lockfile_after,
58
+ gem_version: BundleUpdate::VERSION,
59
+ ran_at: ran_at.utc.iso8601
60
+ }
61
+ request(:post, "/api/v1/reports", payload)
62
+ end
63
+
64
+ # GET /api/v1/reports/:id — used by `bundle_update report` to poll a
65
+ # report that was still generating when create_report returned 202.
66
+ def fetch_report(report_id)
67
+ request(:get, "/api/v1/reports/#{report_id}")
68
+ end
69
+
70
+ private
71
+
72
+ def request(method, path, payload = nil)
73
+ perform(method, path, payload)
74
+ rescue *RETRYABLE_ERRORS
75
+ begin
76
+ perform(method, path, payload)
77
+ rescue *RETRYABLE_ERRORS => e
78
+ Response.new(status: :error, error: :connection_failed, error_detail: e.message)
79
+ end
80
+ end
81
+
82
+ def perform(method, path, payload)
83
+ uri = URI.join(@config.api_url, path)
84
+ http_response = build_http(uri).request(build_request(method, uri, payload))
85
+ parse_response(http_response)
86
+ end
87
+
88
+ def build_http(uri)
89
+ http = Net::HTTP.new(uri.host, uri.port)
90
+ http.use_ssl = uri.scheme == "https"
91
+ http.open_timeout = OPEN_TIMEOUT
92
+ http.read_timeout = READ_TIMEOUT
93
+ http
94
+ end
95
+
96
+ def build_request(method, uri, payload)
97
+ request = method == :post ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
98
+ request["Authorization"] = "Bearer #{@config.api_key}"
99
+ request["User-Agent"] = "bundle_update/#{BundleUpdate::VERSION}"
100
+ if payload
101
+ request["Content-Type"] = "application/json"
102
+ request["Content-Encoding"] = "gzip"
103
+ request.body = gzip(JSON.generate(payload))
104
+ end
105
+ request
106
+ end
107
+
108
+ def parse_response(http_response)
109
+ case http_response.code.to_i
110
+ when 200
111
+ with_parsed_body(http_response) do |data|
112
+ Response.new(status: :ready, report_id: data["report_id"], url: data["url"], markdown: data["markdown"])
113
+ end
114
+ when 202
115
+ with_parsed_body(http_response) do |data|
116
+ Response.new(status: :pending, report_id: data["report_id"], url: data["url"])
117
+ end
118
+ when 401
119
+ Response.new(status: :error, error: :unauthorized)
120
+ when 422
121
+ Response.new(status: :error, error: :validation_error, error_detail: http_response.body)
122
+ else
123
+ Response.new(status: :error, error: :http_error, error_detail: http_response.code)
124
+ end
125
+ end
126
+
127
+ def with_parsed_body(http_response)
128
+ data = JSON.parse(http_response.body.to_s)
129
+ yield data
130
+ rescue JSON::ParserError, TypeError
131
+ Response.new(status: :error, error: :malformed_response)
132
+ end
133
+
134
+ def gzip(string)
135
+ io = StringIO.new
136
+ writer = Zlib::GzipWriter.new(io)
137
+ writer.write(string)
138
+ writer.close
139
+ io.string
140
+ end
141
+ end
142
+ end