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 +4 -4
- data/CHANGELOG.md +42 -0
- data/DECISIONS.md +129 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +232 -1
- data/bundle_update.gemspec +45 -0
- data/exe/bundle_update +7 -0
- data/lib/bundle_update/api_client.rb +142 -0
- data/lib/bundle_update/cli.rb +125 -0
- data/lib/bundle_update/config.rb +172 -0
- data/lib/bundle_update/diff.rb +103 -0
- data/lib/bundle_update/local_report.rb +124 -0
- data/lib/bundle_update/plugin.rb +126 -0
- data/lib/bundle_update/report_writer.rb +104 -0
- data/lib/bundle_update/runner.rb +120 -0
- data/lib/bundle_update/snapshot.rb +54 -0
- data/lib/bundle_update/version.rb +5 -0
- data/lib/bundle_update.rb +12 -2
- data/plugins.rb +17 -0
- metadata +35 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dca4dd6ff6a6b352adc75bb532667af67aaaa9351ff97450195d01834c197508
|
|
4
|
+
data.tar.gz: cdb977853a4317e77999c161db056e1d074690d9a433f0e55bbc4cc8ab0224fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
-
|
|
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,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
|