still_active 1.4.2 → 1.6.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 +20 -0
- data/README.md +100 -19
- data/lib/helpers/alternatives_helper.rb +42 -0
- data/lib/helpers/bot_context.rb +132 -0
- data/lib/helpers/catalog_index.rb +98 -0
- data/lib/helpers/cyclonedx_helper.rb +159 -0
- data/lib/helpers/markdown_helper.rb +20 -2
- data/lib/helpers/ruby_advisory_db.rb +93 -0
- data/lib/helpers/sarif_helper.rb +9 -2
- data/lib/helpers/terminal_helper.rb +27 -3
- data/lib/helpers/version_helper.rb +9 -0
- data/lib/helpers/vulnerability_helper.rb +35 -0
- data/lib/still_active/cli.rb +55 -4
- data/lib/still_active/config.rb +7 -1
- data/lib/still_active/deps_dev_client.rb +1 -0
- data/lib/still_active/options.rb +12 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +35 -7
- data/still_active.gemspec +6 -1
- metadata +22 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e5e5e599cdd630ec6e916800c4abd7df4a3c9d37e24122e80d8859accb1c4e3
|
|
4
|
+
data.tar.gz: 0cb0dfd2c761612f665a9d2f4e0ba20f47bf8b082c42281af09b8429335aa2a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 571797c9863bf6597e7c9474e8cf718019925d37d163002e9d594b137458ca0a6be155dfb7a5069777afe83963fb4dc595026a69c8237ce1aa7d024eb254be99
|
|
7
|
+
data.tar.gz: 4e7ba6a7777973b6fe7f1fa54fc1fad00a06639da18944f0bc89befd6a3e794b1d55eeaca62f33b193bebb54cdb2d6d85c63ae48a73adc722e58a3f194c0aeae
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.0] - 2026-06-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `--alternatives` surfaces up to three maintained alternative gems for any dependency still_active flags as archived or critically abandoned, drawn from the [rubytoolbox/catalog](https://github.com/rubytoolbox/catalog) category data and ranked by total RubyGems downloads. Presented as **leads to verify, not vetted recommendations** — same Ruby Toolbox category does not guarantee a drop-in replacement — reflecting that Ruby has no authoritative successor metadata the way npm (`deprecate`), Go (`// Deprecated:`), or NuGet (alternate-package) do. Opt-in and best-effort: the catalog is fetched once and cached under `XDG_CACHE_HOME` with a 7-day TTL, any fetch/parse failure degrades to silence, and nothing here can block a run or affect `--fail-if-*` exit codes. Leads render in terminal (a dimmed sub-line), markdown (an Alternatives section), JSON (an additive `alternatives` array), and SARIF (appended to the SA001/SA002 result messages); CycloneDX is unchanged. With the flag off, terminal output shows a one-line discoverability hint on flagged gems. Silent when the catalog has no entry for the gem (the common case for niche/long-tail gems). Closes #28.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- The `async` runtime dependency now requires `>= 2.2` (previously unconstrained). 2.2.0 is the verified real minimum — earlier 2.x releases hit a fiber-scheduler `io_read` bug under still_active's concurrent fan-out. A new CI job installs every runtime dependency at its declared gemspec floor and runs the suite on the minimum supported Ruby, so an under-set floor now fails loudly instead of silently.
|
|
12
|
+
|
|
13
|
+
## [1.5.0] - 2026-05-23
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `--cyclonedx[=PATH]` emits a CycloneDX SBOM (stdout by default, or to a file) so the dependency graph plus still_active's signals flow into Trivy / Dependency-Track / Snyk. Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java` / Dependency-Track and `cyclonedx-go` / Trivy both cap at 1.6 as of 2026) — with `--cyclonedx-version=1.7` to opt into the latest. Gem name/version/purl/licenses map to native fields; maintenance signals (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`; vulnerabilities map to the top-level `vulnerabilities[]`. The `serialNumber` is content-derived (two SBOMs of the same lockfile are byte-identical apart from the generation timestamp), so SBOMs diff cleanly.
|
|
18
|
+
- Dependabot/Renovate awareness: when a run is detected as bot-authored (primarily via the PR author in the GitHub event payload — `pull_request.user.login`, the same authoritative signal `dependabot/fetch-metadata` uses, which unlike `GITHUB_ACTOR` survives a human re-running the workflow — falling back to `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Bump extraction tolerates any configured `commit-message.prefix`/scope (`chore(deps):`, `deps:`, …) once the bot is confirmed, while detection stays conservative to avoid false positives on human commits. Best-effort: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`.
|
|
19
|
+
- A warning is emitted when mutually-exclusive output flags are combined (`--baseline`/`--sarif`/`--cyclonedx`), naming which one wins, and when `--cyclonedx-version` is set without `--cyclonedx`.
|
|
20
|
+
- Dual-source vulnerability data: when `bundler-audit` is installed (with a current `bundle audit update` checkout), still_active reads the `rubysec/ruby-advisory-db` advisories through bundler-audit's own loader and merges them with deps.dev results, deduplicating on shared identifiers. Each advisory carries a `source` field (`deps.dev`, `ruby-advisory-db`, or `merged`); deps.dev is preferred for CVSS/title/vector and ruby-advisory-db fills gaps. Opt-in by composition — no second source unless `bundler-audit` is present; falls back silently to deps.dev only otherwise (with a one-line hint to run `bundle audit update`). Closes the "why do bundler-audit and still_active disagree?" gap. See `docs/schema.md` and `docs/rules.md` (SA003).
|
|
21
|
+
- Gem license surfaced from the RubyGems versions payload we already fetch (no extra request). Shows as a `License` column in terminal and markdown output and as an additive `license` field (SPDX identifier, comma-joined when a gem declares more than one) on the JSON per-gem record. `nil`/`-` for git/path sources where no RubyGems metadata exists. See `docs/schema.md`. Read-only metadata only — license *policy* (allow/deny gating) stays the domain of `license_finder`.
|
|
22
|
+
|
|
3
23
|
## [1.4.2] - 2026-05-22
|
|
4
24
|
|
|
5
25
|
### Fixed
|
data/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
`bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities, libyear drift, and archived repos for every gem in your Gemfile.
|
|
6
6
|
|
|
7
|
+
Findings ship as **terminal / markdown / JSON / SARIF / CycloneDX** — SARIF lands in your GitHub Security tab and as inline PR annotations on `Gemfile.lock`; CycloneDX feeds Trivy / Dependency-Track / Snyk. PR mode (`--baseline=FILE`) reports only what got worse since main, so reviewers see one line ("`vcr` newly archived") instead of an absolute snapshot of every dep.
|
|
8
|
+
|
|
7
9
|
[](https://badge.fury.io/rb/still_active)
|
|
8
10
|
[](https://github.com/marketplace/actions/still_active)
|
|
9
11
|

|
|
@@ -11,15 +13,15 @@
|
|
|
11
13
|

|
|
12
14
|
|
|
13
15
|
```
|
|
14
|
-
Name Version Activity OpenSSF Vulns
|
|
15
|
-
|
|
16
|
-
async 2.36.0 (latest) ok 7.1/10 0
|
|
17
|
-
backbone-rails 1.2.3 (latest) archived 3.6/10 0
|
|
18
|
-
bootstrap-slider-rails 9.8.0 (latest) critical - 0
|
|
19
|
-
gitlab-markup 2.0.0 (latest) ok - 0
|
|
20
|
-
local_gem 0.1.0 (path) - - 0
|
|
21
|
-
nested_form 0.3.2 (git) archived 3.3/10 0
|
|
22
|
-
remotipart 1.4.4 (git) critical 3.1/10 0
|
|
16
|
+
Name Version Activity OpenSSF Vulns License
|
|
17
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
async 2.36.0 (latest) ok 7.1/10 0 MIT
|
|
19
|
+
backbone-rails 1.2.3 (latest) archived 3.6/10 0 MIT
|
|
20
|
+
bootstrap-slider-rails 9.8.0 (latest) critical - 0 MIT
|
|
21
|
+
gitlab-markup 2.0.0 (latest) ok - 0 MIT
|
|
22
|
+
local_gem 0.1.0 (path) - - 0 -
|
|
23
|
+
nested_form 0.3.2 (git) archived 3.3/10 0 MIT
|
|
24
|
+
remotipart 1.4.4 (git) critical 3.1/10 0 MIT
|
|
23
25
|
|
|
24
26
|
7 gems: 4 up to date, 0 outdated · 2 active, 2 stale, 2 archived · 0 vulnerabilities
|
|
25
27
|
Ruby 4.0.1 (latest)
|
|
@@ -32,7 +34,7 @@ Ruby 4.0.1 (latest)
|
|
|
32
34
|
| | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
|
|
33
35
|
| ---------------------------- | ----------------- | ---------------------- | ----------------- | ------------------------ |
|
|
34
36
|
| Outdated versions | Yes | - | Yes | Yes |
|
|
35
|
-
| Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev)
|
|
37
|
+
| Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + ruby-advisory-db) |
|
|
36
38
|
| Libyear drift | - | - | Yes | Yes |
|
|
37
39
|
| **Last commit activity** | - | - | - | **Yes** |
|
|
38
40
|
| **Archived repo detection** | - | - | - | **Yes** |
|
|
@@ -41,9 +43,9 @@ Ruby 4.0.1 (latest)
|
|
|
41
43
|
| **Ruby version freshness** | - | - | - | **Yes** (EOL + libyear) |
|
|
42
44
|
| GitLab support | - | - | - | Yes |
|
|
43
45
|
| CI quality gates | - | Exit code | - | Yes (4 flags) |
|
|
44
|
-
| Output formats | Text | Text | Text | Terminal, JSON, Markdown |
|
|
46
|
+
| Output formats | Text | Text | Text | Terminal, JSON, Markdown, SARIF, CycloneDX |
|
|
45
47
|
|
|
46
|
-
The bolded rows are the gap `still_active` fills: nobody else answers "is the maintainer still around?" The CVE column is worth a closer look: `bundler-audit` and `still_active`
|
|
48
|
+
The bolded rows are the gap `still_active` fills: nobody else answers "is the maintainer still around?" The CVE column is worth a closer look: `bundler-audit` reads `ruby-advisory-db` and `still_active` reads `deps.dev`, which sometimes diverge. **If `bundler-audit` is installed alongside `still_active`, we read its `ruby-advisory-db` checkout too and merge the results** (deduplicated, each advisory tagged with its `source`) — so running both no longer means reconciling two different vuln counts by hand.
|
|
47
49
|
|
|
48
50
|
## Installation
|
|
49
51
|
|
|
@@ -51,6 +53,8 @@ The bolded rows are the gap `still_active` fills: nobody else answers "is the ma
|
|
|
51
53
|
gem install still_active
|
|
52
54
|
```
|
|
53
55
|
|
|
56
|
+
**Requires an actively-maintained Ruby.** The gemspec's `required_ruby_version` floor tracks Ruby's [EOL schedule](https://endoflife.date/ruby); running a maintenance auditor on an unmaintained runtime would be a bit rich. You don't have to run it *on* the Ruby you're auditing, though: still_active reports on the version your project pins in `Gemfile.lock`, so run it from any current Ruby (locally, in CI, or via the [`still_active-action`](https://github.com/SeanLF/still_active-action)) and it will still flag an EOL target.
|
|
57
|
+
|
|
54
58
|
## Quick Start
|
|
55
59
|
|
|
56
60
|
```bash
|
|
@@ -97,7 +101,10 @@ Usage: still_active [options]
|
|
|
97
101
|
--terminal Coloured terminal output (default in TTY)
|
|
98
102
|
--markdown Markdown table output
|
|
99
103
|
--json JSON output (default when piped)
|
|
104
|
+
--alternatives Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems
|
|
100
105
|
--sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
|
|
106
|
+
--cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path)
|
|
107
|
+
--cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7
|
|
101
108
|
--baseline=PATH Compare current state to baseline JSON; emit markdown deltas
|
|
102
109
|
--github-oauth-token=TOKEN GitHub OAuth token to make API calls
|
|
103
110
|
--gitlab-token=TOKEN GitLab personal access token for API calls
|
|
@@ -141,6 +148,7 @@ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
|
|
|
141
148
|
"archived": false,
|
|
142
149
|
"scorecard_score": 7.1,
|
|
143
150
|
"vulnerability_count": 0,
|
|
151
|
+
"license": "MIT",
|
|
144
152
|
"libyear": 0.0
|
|
145
153
|
},
|
|
146
154
|
"nested_form": {
|
|
@@ -174,15 +182,25 @@ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
|
|
|
174
182
|
still_active --markdown
|
|
175
183
|
```
|
|
176
184
|
|
|
177
|
-
| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |
|
|
178
|
-
| -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- |
|
|
179
|
-
| | ✅ | 7.1/10 | ✅ | [async](https://github.com/socketry/async) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | ❓ | [2026/01](https://github.com/socketry/async) | 0.0y |
|
|
180
|
-
| 🚩 | ✅ | 3.6/10 | ✅ | [backbone-rails](https://github.com/aflatter/backbone-rails) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | ❓ | [2016/02](https://github.com/aflatter/backbone-rails) | 0.0y |
|
|
181
|
-
| ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - |
|
|
182
|
-
| 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - |
|
|
185
|
+
| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | license |
|
|
186
|
+
| -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- | ------- |
|
|
187
|
+
| | ✅ | 7.1/10 | ✅ | [async](https://github.com/socketry/async) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | ❓ | [2026/01](https://github.com/socketry/async) | 0.0y | MIT |
|
|
188
|
+
| 🚩 | ✅ | 3.6/10 | ✅ | [backbone-rails](https://github.com/aflatter/backbone-rails) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | ❓ | [2016/02](https://github.com/aflatter/backbone-rails) | 0.0y | MIT |
|
|
189
|
+
| ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - | - |
|
|
190
|
+
| 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - | MIT |
|
|
183
191
|
|
|
184
192
|
**Ruby 4.0.1** (latest) ✅
|
|
185
193
|
|
|
194
|
+
**CycloneDX** -- a standards-track SBOM so your dependency graph and still_active's signals flow into Trivy, Dependency-Track, or Snyk:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
still_active --cyclonedx # CycloneDX 1.6 to stdout
|
|
198
|
+
still_active --cyclonedx=sbom.json # write to a file
|
|
199
|
+
still_active --cyclonedx --cyclonedx-version=1.7
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java`/Dependency-Track and `cyclonedx-go`/Trivy both cap at 1.6 as of 2026); `--cyclonedx-version=1.7` opts into the latest. Gem name/version/`purl`/licenses and vulnerabilities map to native CycloneDX fields; maintenance signals with no native home (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`. The `serialNumber` is content-derived, so two SBOMs of the same lockfile differ only by their generation timestamp.
|
|
203
|
+
|
|
186
204
|
### SARIF output (GitHub Code Scanning)
|
|
187
205
|
|
|
188
206
|
Emit findings as SARIF 2.1.0 — they show up in the GitHub Security tab and as inline annotations on `Gemfile.lock` in pull requests.
|
|
@@ -245,6 +263,53 @@ In CI, capture a baseline on main and compare on PR branches. Exits 1 if any reg
|
|
|
245
263
|
|
|
246
264
|
The diff supersedes `--sarif`, `--terminal`, `--markdown`, and `--json` when set.
|
|
247
265
|
|
|
266
|
+
When a run is detected as Dependabot- or Renovate-authored (via `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject), the report leads with a one-line narrative — "Dependabot bump: `rack` 2.0.0 → 2.0.6" — and `--json` gains a top-level `pr_context`. Detection is best-effort and conservative: it never produces a false positive on an ordinary commit, and a miss costs only the narrative line.
|
|
267
|
+
|
|
268
|
+
### Alongside `dependency-review-action`
|
|
269
|
+
|
|
270
|
+
GitHub's first-party [`dependency-review-action`](https://github.com/actions/dependency-review-action) runs server-side on PRs and surfaces **vulnerabilities, licenses, and OpenSSF Scorecard** scores from GitHub's dependency-graph diff. It does not surface maintenance signals — last-commit activity, archived repos, libyear, Ruby EOL, or yanked versions — and is GitHub.com / GHES only. `still_active` is the complement, not a replacement:
|
|
271
|
+
|
|
272
|
+
| | `dependency-review-action` | `still_active` |
|
|
273
|
+
| ---------------------------- | ---------------------------------- | ------------------------------------------- |
|
|
274
|
+
| Platform | GitHub.com / GHES only | Any CI |
|
|
275
|
+
| Languages | Multi (GitHub dep graph) | Ruby |
|
|
276
|
+
| Vulnerabilities | GHSA | deps.dev + ruby-advisory-db (merged) |
|
|
277
|
+
| Licenses | Yes (allow/deny gating) | Surfaced (no gating) |
|
|
278
|
+
| OpenSSF Scorecard | Yes (display) | Yes (display + threshold) |
|
|
279
|
+
| **Last-commit activity** | - | **Yes** |
|
|
280
|
+
| **Archived repo detection** | - | **Yes** |
|
|
281
|
+
| **Libyear drift** | - | **Yes** |
|
|
282
|
+
| **Ruby EOL detection** | - | **Yes** |
|
|
283
|
+
| **Yanked version detection** | - | **Yes** |
|
|
284
|
+
| Diff vs base | Native (GitHub API) | `--baseline=FILE` |
|
|
285
|
+
| Output | Inline PR annotations | Terminal / Markdown / JSON / SARIF / CycloneDX |
|
|
286
|
+
|
|
287
|
+
Run both: let `dependency-review-action` gate CVEs and licenses, and `still_active` add the maintenance lens on the same PR.
|
|
288
|
+
|
|
289
|
+
```yaml
|
|
290
|
+
on: pull_request
|
|
291
|
+
|
|
292
|
+
jobs:
|
|
293
|
+
dependency-review:
|
|
294
|
+
runs-on: ubuntu-latest
|
|
295
|
+
steps:
|
|
296
|
+
- uses: actions/checkout@v4
|
|
297
|
+
- uses: actions/dependency-review-action@v4
|
|
298
|
+
with:
|
|
299
|
+
fail-on-severity: high
|
|
300
|
+
show-openssf-scorecard: true
|
|
301
|
+
|
|
302
|
+
maintenance-review:
|
|
303
|
+
runs-on: ubuntu-latest
|
|
304
|
+
steps:
|
|
305
|
+
- uses: actions/checkout@v4
|
|
306
|
+
- uses: ruby/setup-ruby@v1
|
|
307
|
+
with: { ruby-version: ".ruby-version", bundler-cache: true }
|
|
308
|
+
- uses: SeanLF/still_active-action@v0
|
|
309
|
+
with:
|
|
310
|
+
fail-if-critical: true
|
|
311
|
+
```
|
|
312
|
+
|
|
248
313
|
### CI quality gating
|
|
249
314
|
|
|
250
315
|
Use exit-code flags to fail CI pipelines based on dependency status:
|
|
@@ -277,12 +342,28 @@ Activity is determined by the most recent signal across last commit date, latest
|
|
|
277
342
|
- **stale**: last activity between 1 and 3 years ago (configurable with `--warning-range-end`)
|
|
278
343
|
- **critical**: last activity over 3 years ago
|
|
279
344
|
|
|
345
|
+
### Alternative gem leads (opt-in)
|
|
346
|
+
|
|
347
|
+
When a gem is flagged archived or critical, `--alternatives` surfaces up to three maintained gems from the same [Ruby Toolbox](https://www.ruby-toolbox.com) category, ranked by total downloads:
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
still_active --gems=paperclip --alternatives
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```text
|
|
354
|
+
↳ leads (Ruby Toolbox): shrine · carrierwave · kt-paperclip (verify fit)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
These are **leads, not recommendations**: same-category does not mean drop-in replacement, so verify fit before switching. Ruby has no authoritative "use instead" metadata (unlike npm `deprecate`, Go's `// Deprecated:`, or NuGet's alternate-package field), so this is a best-effort heuristic. It is silent when the catalog has no entry for the gem, and the feature never blocks or fails a run. Leads appear in terminal, markdown, JSON, and SARIF output. When the flag is off, terminal output shows a one-line hint on flagged gems that the option exists (other formats stay silent).
|
|
358
|
+
|
|
280
359
|
### Data sources
|
|
281
360
|
|
|
282
|
-
- **Versions
|
|
361
|
+
- **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages)
|
|
283
362
|
- **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest) or [GitLab](https://docs.gitlab.com/ee/api/) API
|
|
284
363
|
- **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
|
|
364
|
+
- **Additional advisories** from [ruby-advisory-db](https://github.com/rubysec/ruby-advisory-db), merged in when `bundler-audit` is installed alongside (run `bundle audit update` to keep its checkout current)
|
|
285
365
|
- **Ruby version freshness** from [endoflife.date](https://endoflife.date)
|
|
366
|
+
- **Alternative gem leads** (with `--alternatives`) from the [rubytoolbox/catalog](https://github.com/rubytoolbox/catalog) category data
|
|
286
367
|
|
|
287
368
|
### Configuration defaults
|
|
288
369
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gems"
|
|
4
|
+
|
|
5
|
+
module StillActive
|
|
6
|
+
# Turns a gem's catalog siblings into ranked "leads" -- the most-downloaded
|
|
7
|
+
# still-published alternatives. Best-effort: a failed lookup drops that
|
|
8
|
+
# candidate, never the feature.
|
|
9
|
+
module AlternativesHelper
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
MAX_SIBLINGS_CONSIDERED = 40 # bound the download lookups for huge categories
|
|
13
|
+
DEFAULT_LIMIT = 3
|
|
14
|
+
|
|
15
|
+
def leads_for(gem_name:, index:, limit: DEFAULT_LIMIT)
|
|
16
|
+
return [] if index.nil?
|
|
17
|
+
|
|
18
|
+
# Bound the per-gem download lookups so a huge category can't trigger
|
|
19
|
+
# dozens of HTTP calls. This is a catalog-order prefix, so a very large
|
|
20
|
+
# category could leave a popular sibling past the cap out of the ranking;
|
|
21
|
+
# acceptable for best-effort leads where we only ever surface a few.
|
|
22
|
+
# (CatalogIndex already reduces owner/repo slugs to their gem-name tail,
|
|
23
|
+
# so every entry here is a plain name rankable by downloads.)
|
|
24
|
+
siblings = (index[gem_name] || []).first(MAX_SIBLINGS_CONSIDERED)
|
|
25
|
+
return [] if siblings.empty?
|
|
26
|
+
|
|
27
|
+
siblings
|
|
28
|
+
.filter_map { |name| (count = downloads(name)) && [name, count] }
|
|
29
|
+
.max_by(limit) { |_name, count| count }
|
|
30
|
+
.map(&:first)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def downloads(gem_name)
|
|
36
|
+
info = Gems.info(gem_name)
|
|
37
|
+
info && info["downloads"]
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module StillActive
|
|
7
|
+
# Best-effort detection of a Dependabot/Renovate-authored run, so --baseline
|
|
8
|
+
# reports can lead with a narrative ("Dependabot bump: rack 2.0.0 → 2.0.6")
|
|
9
|
+
# instead of an unattributed list. Detection is heuristic: false negatives are
|
|
10
|
+
# fine (we just lose the narrative), false positives are not, so the subject
|
|
11
|
+
# patterns are anchored and require the literal bump/update keyword.
|
|
12
|
+
module BotContext
|
|
13
|
+
extend self
|
|
14
|
+
|
|
15
|
+
# Dependabot's *default* subject is "Bump X from Y to Z" (capitalized, no
|
|
16
|
+
# prefix). The `from … to …` skeleton rarely occurs in human commits, so it
|
|
17
|
+
# is safe unprefixed. The conventional-commit prefix only appears when configured.
|
|
18
|
+
DEPENDABOT_SUBJECT = /\A(?:build\(deps(?:-dev)?\):\s*)?bump (\S+) from (\S+) to (\S+)/i
|
|
19
|
+
|
|
20
|
+
# Renovate's default is "Update dependency X to vN.…" — note the **required**
|
|
21
|
+
# `v`+digit version. Matching a bare "to <word>" would false-positive on ordinary
|
|
22
|
+
# commits ("Update README to mention SARIF"), so we anchor on the v-prefixed
|
|
23
|
+
# version. False negatives (a no-`v` Renovate config) are acceptable; false
|
|
24
|
+
# positives are not. The `v` is consumed, so the captured version excludes it.
|
|
25
|
+
RENOVATE_SUBJECT = /\A(?:(?:chore|fix|build)\(deps(?:-dev)?\):\s*)?update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i
|
|
26
|
+
|
|
27
|
+
# Unanchored variants used only to EXTRACT the bump *after* a bot is already
|
|
28
|
+
# confirmed (via GITHUB_ACTOR / branch / the anchored subject above). Because
|
|
29
|
+
# detection has already happened, these can ignore whatever commit-message
|
|
30
|
+
# prefix or scope Dependabot/Renovate is configured with and just find the
|
|
31
|
+
# "bump X from Y to Z" / "update X to vN" skeleton anywhere in the subject.
|
|
32
|
+
DEPENDABOT_BUMP = /bump (\S+) from (\S+) to (\S+)/i
|
|
33
|
+
RENOVATE_BUMP = /update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i
|
|
34
|
+
|
|
35
|
+
# Returns { bot: "dependabot" | "renovate", bumps: [{ gem:, from:, to: }] }
|
|
36
|
+
# or nil when no bot signal is present. `bumps` is parsed from the head
|
|
37
|
+
# commit subject; a grouped or unparseable subject yields an empty list.
|
|
38
|
+
def detect(env: ENV, head_subject: head_commit_subject)
|
|
39
|
+
bot = detect_bot(env: env, head_subject: head_subject)
|
|
40
|
+
return if bot.nil?
|
|
41
|
+
|
|
42
|
+
{ bot: bot, bumps: bumps_from(bot, head_subject) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A one-line, format-agnostic narrative for the detected context.
|
|
46
|
+
def summary(context)
|
|
47
|
+
label = context[:bot] == "renovate" ? "Renovate" : "Dependabot"
|
|
48
|
+
bumps = context[:bumps]
|
|
49
|
+
|
|
50
|
+
case bumps.length
|
|
51
|
+
when 0 then "#{label} dependency update"
|
|
52
|
+
when 1 then single_bump_summary(label, bumps.first)
|
|
53
|
+
else "#{label}: #{bumps.length} dependency updates"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def detect_bot(env:, head_subject:)
|
|
60
|
+
# The PR author from the event payload is the authoritative signal — it's
|
|
61
|
+
# what `dependabot/fetch-metadata` keys on, and unlike GITHUB_ACTOR it
|
|
62
|
+
# doesn't flip to a human who re-runs the workflow or pushes to the branch.
|
|
63
|
+
login = pr_author_login(env)
|
|
64
|
+
return "dependabot" if login == "dependabot[bot]"
|
|
65
|
+
return "renovate" if login == "renovate[bot]"
|
|
66
|
+
|
|
67
|
+
actor = env["GITHUB_ACTOR"]
|
|
68
|
+
return "dependabot" if actor == "dependabot[bot]"
|
|
69
|
+
return "renovate" if actor == "renovate[bot]"
|
|
70
|
+
|
|
71
|
+
ref = env["GITHUB_HEAD_REF"] || current_branch
|
|
72
|
+
return "dependabot" if ref&.start_with?("dependabot/")
|
|
73
|
+
return "renovate" if ref&.start_with?("renovate/", "renovate-bot/")
|
|
74
|
+
|
|
75
|
+
return "dependabot" if head_subject&.match?(DEPENDABOT_SUBJECT)
|
|
76
|
+
return "renovate" if head_subject&.match?(RENOVATE_SUBJECT)
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reads pull_request.user.login from the GitHub Actions event payload
|
|
82
|
+
# (GITHUB_EVENT_PATH). Returns nil off Actions, on non-PR events, or if the
|
|
83
|
+
# file is missing/unreadable/malformed — all of which just fall through to
|
|
84
|
+
# the weaker signals. TypeError covers a payload that parses but has the
|
|
85
|
+
# wrong shape (e.g. a top-level array, or pull_request/user not a Hash);
|
|
86
|
+
# this method must never raise, since detect runs unguarded and a cosmetic
|
|
87
|
+
# narrative must not be able to abort the audit.
|
|
88
|
+
def pr_author_login(env)
|
|
89
|
+
path = env["GITHUB_EVENT_PATH"]
|
|
90
|
+
return if path.nil? || !File.file?(path)
|
|
91
|
+
|
|
92
|
+
JSON.parse(File.read(path)).dig("pull_request", "user", "login")
|
|
93
|
+
rescue JSON::ParserError, SystemCallError, TypeError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def bumps_from(bot, subject)
|
|
98
|
+
return [] if subject.nil?
|
|
99
|
+
|
|
100
|
+
if bot == "dependabot" && (match = subject.match(DEPENDABOT_BUMP))
|
|
101
|
+
[{ gem: match[1], from: match[2], to: match[3] }]
|
|
102
|
+
elsif bot == "renovate" && (match = subject.match(RENOVATE_BUMP))
|
|
103
|
+
[{ gem: match[1], from: nil, to: match[2] }]
|
|
104
|
+
else
|
|
105
|
+
[]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def single_bump_summary(label, bump)
|
|
110
|
+
arrow = bump[:from] ? "#{bump[:from]} → #{bump[:to]}" : "→ #{bump[:to]}"
|
|
111
|
+
verb = label == "Renovate" ? "update" : "bump"
|
|
112
|
+
"#{label} #{verb}: #{bump[:gem]} #{arrow}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# SystemCallError (not just Errno::ENOENT) so a git that's missing *or*
|
|
116
|
+
# unlaunchable can't crash a run over a cosmetic narrative. git *logic*
|
|
117
|
+
# failures surface as a non-zero status, not an exception, and yield nil.
|
|
118
|
+
def current_branch
|
|
119
|
+
out, _, status = Open3.capture3("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
120
|
+
status.success? ? out.strip : nil
|
|
121
|
+
rescue SystemCallError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def head_commit_subject
|
|
126
|
+
out, _, status = Open3.capture3("git", "log", "-1", "--pretty=%s")
|
|
127
|
+
status.success? ? out.strip : nil
|
|
128
|
+
rescue SystemCallError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "rubygems/package"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require "json"
|
|
8
|
+
require "open-uri"
|
|
9
|
+
|
|
10
|
+
module StillActive
|
|
11
|
+
# Optional source of "alternative gem" leads: the rubytoolbox/catalog repo
|
|
12
|
+
# (MIT) mapped to gem -> co-category siblings. Fetched once and cached; every
|
|
13
|
+
# path is best-effort, returning nil/empty so a miss just means no leads.
|
|
14
|
+
module CatalogIndex
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
REPO = "rubytoolbox/catalog"
|
|
18
|
+
CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
19
|
+
MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024 # the catalog is ~50 KB; cap to avoid surprises
|
|
20
|
+
|
|
21
|
+
# Returns { gem => [siblings] } or nil. Never raises.
|
|
22
|
+
def load
|
|
23
|
+
cached = read_cache
|
|
24
|
+
return cached if cached
|
|
25
|
+
|
|
26
|
+
blob = download
|
|
27
|
+
index = build_index(blob)
|
|
28
|
+
write_cache(index)
|
|
29
|
+
index
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
warn("still_active: could not load Ruby Toolbox catalog for alternatives (#{e.class}); skipping leads")
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parse a gzipped catalog tarball into { gem_name => [sibling gem names] }.
|
|
36
|
+
def build_index(tar_gz_blob)
|
|
37
|
+
categories = []
|
|
38
|
+
|
|
39
|
+
reader = Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(tar_gz_blob)))
|
|
40
|
+
reader.each do |entry|
|
|
41
|
+
next unless entry.file?
|
|
42
|
+
next unless entry.full_name.match?(%r{/catalog/.+\.ya?ml$})
|
|
43
|
+
next if File.basename(entry.full_name) == "_meta.yml"
|
|
44
|
+
|
|
45
|
+
data = YAML.safe_load(entry.read)
|
|
46
|
+
next unless data.is_a?(Hash) && data["projects"].is_a?(Array)
|
|
47
|
+
|
|
48
|
+
categories << data["projects"].map { |p| p.to_s.split("/").last }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
build_siblings(categories)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def cache_path
|
|
57
|
+
base = ENV["XDG_CACHE_HOME"]
|
|
58
|
+
base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
|
|
59
|
+
File.join(base, "still_active", "catalog-siblings.json")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_cache
|
|
63
|
+
path = cache_path
|
|
64
|
+
return unless File.exist?(path)
|
|
65
|
+
return if Time.now - File.mtime(path) > CACHE_TTL_SECONDS
|
|
66
|
+
|
|
67
|
+
JSON.parse(File.read(path))
|
|
68
|
+
rescue JSON::ParserError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write_cache(index)
|
|
73
|
+
path = cache_path
|
|
74
|
+
require "fileutils"
|
|
75
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
76
|
+
File.write(path, JSON.dump(index))
|
|
77
|
+
rescue SystemCallError
|
|
78
|
+
nil # an unwritable cache dir must not break the feature
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def download
|
|
82
|
+
url = StillActive.config.github_client.archive_link(REPO, format: "tarball", ref: "main")
|
|
83
|
+
URI.open(url) { |io| io.read(MAX_DOWNLOAD_BYTES) } # rubocop:disable Security/Open
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_siblings(categories)
|
|
87
|
+
siblings = Hash.new { |h, k| h[k] = [] }
|
|
88
|
+
|
|
89
|
+
categories.each do |members|
|
|
90
|
+
members.each do |gem_name|
|
|
91
|
+
siblings[gem_name].concat(members - [gem_name])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
siblings.transform_values(&:uniq)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|