kettle-jem-appraisals 0.1.4 → 0.1.5
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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +28 -1
- data/README.md +39 -15
- data/lib/kettle/jem/appraisals/active_record_support_gemfile_generator.rb +115 -29
- data/lib/kettle/jem/appraisals/cli.rb +99 -14
- data/lib/kettle/jem/appraisals/matrix_builder.rb +16 -1
- data/lib/kettle/jem/appraisals/modular_gemfile_generator.rb +62 -14
- data/lib/kettle/jem/appraisals/version.rb +1 -1
- data/lib/kettle/jem/appraisals.rb +1 -0
- data.tar.gz.sig +0 -0
- metadata +24 -4
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb5a39f890084799496748b7329463f78f41ece95e22ea867acaf93e4a9f90e4
|
|
4
|
+
data.tar.gz: 19be289c164635ed3dd05c9628b82a1d03f823edd34ae958e15362d3df188bf9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f8d5f93acaf50f246d5725c7f32dd8e996c71e5c10192591c5da0beab84600166b26caf328c948aeef1b79181c8d48b23ae90535e5a79d97fbb792447fc047cf
|
|
7
|
+
data.tar.gz: 206fbde205782a4caf791b7527c30b32375d8cb5fc31b6811ed333e0dbc6027603dd4fa7da47ce24969f7eb6eb5d323d377e3d50562c09001eea5520271852cc
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/CHANGELOG.md
CHANGED
|
@@ -30,6 +30,31 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
30
30
|
|
|
31
31
|
### Security
|
|
32
32
|
|
|
33
|
+
## [0.1.5] - 2026-09-19
|
|
34
|
+
|
|
35
|
+
- TAG: [v0.1.5][0.1.5t]
|
|
36
|
+
- COVERAGE: 95.68% -- 975/1019 lines in 15 files
|
|
37
|
+
- BRANCH COVERAGE: 83.93% -- 282/336 branches in 15 files
|
|
38
|
+
- 92.13% documented
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- Security floors from kettle-rb `Kettle::Rb::GemFloors` in generated per-version gemfiles for tracked gems and their tracked sub-dependencies (e.g. `gem "activerecord", "~> 7.1.0", ">= 7.1.6"`)
|
|
43
|
+
- `--resolve` warns, and generated gemfiles carry a `# kettle-rb:` comment, when a series has advisories no release fixes or an exact patch version is below its security floor
|
|
44
|
+
- Runtime dependency on kettle-rb `>= 0.1.14`
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- ActiveRecord support gemfiles take their sqlite3 requirements from kettle-rb `Kettle::Rb::GemDependencies` instead of hardcoded ranges, are written per requirement under `gemfiles/modular/activerecord_support/` (e.g. `v6.0.gemfile` for ActiveRecord 6.0–7.0), and only for the ActiveRecord versions in the matrix
|
|
49
|
+
- `activerecord_support.gemfile` / `activerecord_support_modern.gemfile` generated by 0.1.4 and earlier are removed (hand-maintained ones are kept)
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- `--resolve` now exits non-zero when `bin/appraisal generate` fails, instead of reporting "Resolve complete"
|
|
54
|
+
- `-h`/`--help` prints usage instead of running scaffold mode, and unknown options are rejected before any file is written
|
|
55
|
+
- A selected version whose optimal Ruby bucket falls below the project's Ruby floor (e.g. activerecord 5.2 for a gem requiring Ruby >= 2.5) is assigned to the lowest bucket that can run it instead of being silently dropped; selected versions that still cannot be assigned are reported
|
|
56
|
+
- `semver` mode includes the lowest minor allowed by a gem's `requirements` (e.g. activerecord 7.0 for `>= 7.0`)
|
|
57
|
+
|
|
33
58
|
## [0.1.4] - 2026-09-14
|
|
34
59
|
|
|
35
60
|
- TAG: [v0.1.4][0.1.4t]
|
|
@@ -194,7 +219,9 @@ Please file a bug if you notice a violation of semantic versioning.
|
|
|
194
219
|
- kettle-jem-template-20260802-001 - Devcontainer JSON files now merge as JSONC,
|
|
195
220
|
preserving comments and trailing commas during template updates.
|
|
196
221
|
|
|
197
|
-
[Unreleased]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.
|
|
222
|
+
[Unreleased]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.5...HEAD
|
|
223
|
+
[0.1.5]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.4...v0.1.5
|
|
224
|
+
[0.1.5t]: https://github.com/appraisal-rb/kettle-jem-appraisals/releases/tag/v0.1.5
|
|
198
225
|
[0.1.4]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.3...v0.1.4
|
|
199
226
|
[0.1.4t]: https://github.com/appraisal-rb/kettle-jem-appraisals/releases/tag/v0.1.4
|
|
200
227
|
[0.1.3]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.2...v0.1.3
|
data/README.md
CHANGED
|
@@ -381,19 +381,24 @@ database driver whose gem differs per engine and per ActiveRecord version:
|
|
|
381
381
|
`sqlite3` on MRI/TruffleRuby, `activerecord-jdbcsqlite3-adapter` (plus
|
|
382
382
|
`bigdecimal`) on JRuby. When `activerecord` is a tier1 gem, `--resolve`:
|
|
383
383
|
|
|
384
|
-
- writes `gemfiles/modular/activerecord_support
|
|
385
|
-
ActiveRecord
|
|
386
|
-
|
|
387
|
-
|
|
384
|
+
- writes one support gemfile under `gemfiles/modular/activerecord_support/` for
|
|
385
|
+
each `sqlite3` requirement the matrix's ActiveRecord versions need. The
|
|
386
|
+
requirements come from [kettle-rb][kettle-rb]'s `Kettle::Rb::GemDependencies`
|
|
387
|
+
(what each ActiveRecord series' sqlite3 adapter declares), and each file is
|
|
388
|
+
named after the oldest ActiveRecord series sharing the requirement (for
|
|
389
|
+
example `v6.0.gemfile` covers 6.0–7.0 with `sqlite3 ~> 1.4`, and `v8.0.gemfile`
|
|
390
|
+
covers 8.0+ with `sqlite3 >= 2.1`). Each file has a JRuby platform block for
|
|
391
|
+
the JDBC adapter, whose release series tracks ActiveRecord so Bundler picks
|
|
388
392
|
the matching adapter;
|
|
389
393
|
- adds the matching support gemfile to each generated entry, based on that
|
|
390
|
-
entry's ActiveRecord version (unless `appraisal_gemfiles` already lists one
|
|
391
|
-
|
|
394
|
+
entry's ActiveRecord version (unless `appraisal_gemfiles` already lists one);
|
|
395
|
+
- removes `activerecord_support.gemfile` / `activerecord_support_modern.gemfile`
|
|
396
|
+
written by kettle-jem-appraisals 0.1.4 and earlier.
|
|
392
397
|
|
|
393
398
|
Existing support gemfiles that do not carry the `# Generated by
|
|
394
399
|
kettle-jem-appraisals` header are treated as hand-maintained and left untouched.
|
|
395
400
|
Set `activerecord_support: false` to opt out entirely (for example, when the
|
|
396
|
-
suite uses PostgreSQL). Point the root `Gemfile` at the
|
|
401
|
+
suite uses PostgreSQL). Point the root `Gemfile` at the newest support gemfile
|
|
397
402
|
via kettle-jem's `test_bundle.gemfiles`, and select the JDBC adapter in your spec
|
|
398
403
|
helper on JRuby:
|
|
399
404
|
|
|
@@ -408,6 +413,21 @@ adapter = (RUBY_PLATFORM == "java" && ActiveRecord.version < Gem::Version.new("7
|
|
|
408
413
|
ActiveRecord::Base.establish_connection(adapter: adapter, database: ":memory:")
|
|
409
414
|
```
|
|
410
415
|
|
|
416
|
+
### Security floors
|
|
417
|
+
|
|
418
|
+
Per-version gemfiles for gems tracked by kettle-rb's `Kettle::Rb::GemFloors`
|
|
419
|
+
(activemodel, activerecord, activesupport, sqlite3) require the minor series'
|
|
420
|
+
security floor, e.g. `gem "activerecord", "~> 7.1.0", ">= 7.1.6"`: the newest
|
|
421
|
+
patch that keeps the series' original minimum Ruby, never below a patched
|
|
422
|
+
advisory. Tracked sub-dependencies get their floor too. Exact patch versions
|
|
423
|
+
(`patch` mode) stay pinned.
|
|
424
|
+
|
|
425
|
+
When a series has advisories that no release fixes (for example activerecord
|
|
426
|
+
5.2–7.0 and CVE-2025-55193), or an exact patch version is below its floor,
|
|
427
|
+
`--resolve` warns and the generated gemfile carries a `# kettle-rb:` comment.
|
|
428
|
+
Support gemfiles likewise note sqlite3 advisories that no allowed sqlite3
|
|
429
|
+
release fixes. Update kettle-rb to pick up new advisories.
|
|
430
|
+
|
|
411
431
|
### Optimal bucket assignment
|
|
412
432
|
|
|
413
433
|
Rather than cross-producting every gem version with every Ruby series,
|
|
@@ -450,14 +470,17 @@ This generates:
|
|
|
450
470
|
|
|
451
471
|
### CLI flags
|
|
452
472
|
|
|
453
|
-
| Flag
|
|
454
|
-
|
|
455
|
-
| `--scaffold`
|
|
456
|
-
| `--resolve`
|
|
457
|
-
| `--force`
|
|
473
|
+
| Flag | Description |
|
|
474
|
+
|-------------------|---------------------------------------------|
|
|
475
|
+
| `--scaffold` | Force scaffold mode (even if config exists) |
|
|
476
|
+
| `--resolve` | Force resolve mode |
|
|
477
|
+
| `--force` | Bypass freshness TTL and re-resolve |
|
|
478
|
+
| `-v`, `--version` | Print the version and exit |
|
|
479
|
+
| `-h`, `--help` | Print usage and exit |
|
|
458
480
|
|
|
459
|
-
Without
|
|
460
|
-
resolve otherwise.
|
|
481
|
+
Without a mode flag, the CLI auto-detects: scaffold if no versions are configured,
|
|
482
|
+
resolve otherwise. Unknown options exit with an error before any file is written.
|
|
483
|
+
`--resolve` exits non-zero when `bin/appraisal generate` fails.
|
|
461
484
|
|
|
462
485
|
### Re-resolving
|
|
463
486
|
|
|
@@ -651,6 +674,7 @@ Thanks for RTFM. ☺️
|
|
|
651
674
|
[✉️ruby-forum]: https://www.rubyforum.org/tag/appraisal-rb
|
|
652
675
|
|
|
653
676
|
[✇bundle-group-pattern]: https://gist.github.com/pboling/4564780
|
|
677
|
+
[kettle-rb]: https://github.com/kettle-dev/kettle-rb
|
|
654
678
|
[⛳️gem-namespace]: https://github.com/appraisal-rb/kettle-jem-appraisals
|
|
655
679
|
[⛳️namespace-img]: https://img.shields.io/badge/namespace-Kettle::Jem::Appraisals-3C2D2D.svg?style=square&logo=ruby&logoColor=white
|
|
656
680
|
[⛳️gem-name]: https://clickgems.clickhouse.com/dashboard/kettle-jem-appraisals
|
|
@@ -756,7 +780,7 @@ Thanks for RTFM. ☺️
|
|
|
756
780
|
[📌gitmoji]: https://gitmoji.dev
|
|
757
781
|
[📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
|
|
758
782
|
[🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
759
|
-
[🧮kloc-img]: https://img.shields.io/badge/KLOC-
|
|
783
|
+
[🧮kloc-img]: https://img.shields.io/badge/KLOC-1.019-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
|
|
760
784
|
[🔐security]: https://github.com/appraisal-rb/kettle-jem-appraisals/blob/main/SECURITY.md
|
|
761
785
|
[🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
|
|
762
786
|
[📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "kettle/rb"
|
|
4
5
|
|
|
5
6
|
module Kettle
|
|
6
7
|
module Jem
|
|
@@ -9,8 +10,13 @@ module Kettle
|
|
|
9
10
|
# database driver on every engine: +sqlite3+ on MRI/TruffleRuby and
|
|
10
11
|
# +activerecord-jdbcsqlite3-adapter+ (plus +bigdecimal+) on JRuby.
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
13
|
+
# The sqlite3 requirement comes from +Kettle::Rb::GemDependencies+, which
|
|
14
|
+
# records what each ActiveRecord series' sqlite3 adapter declares. One
|
|
15
|
+
# support gemfile is written per run of consecutive ActiveRecord series
|
|
16
|
+
# sharing a requirement, named after the oldest series in the run
|
|
17
|
+
# (+gemfiles/modular/activerecord_support/v6.0.gemfile+ covers 6.0–7.0).
|
|
18
|
+
# ActiveRecord newer than the recorded series uses the newest run.
|
|
19
|
+
#
|
|
14
20
|
# The JDBC adapter's release series tracks ActiveRecord (52.x, 61.x, 72.x…),
|
|
15
21
|
# so an open requirement lets Bundler pick the adapter matching the
|
|
16
22
|
# resolved ActiveRecord.
|
|
@@ -20,33 +26,32 @@ module Kettle
|
|
|
20
26
|
#
|
|
21
27
|
# @example
|
|
22
28
|
# generator = ActiveRecordSupportGemfileGenerator.new(base_dir: "/path/to/gem")
|
|
23
|
-
# generator.generate
|
|
24
|
-
# generator.eval_path_for("7.1.6") #=> "modular/activerecord_support.gemfile"
|
|
29
|
+
# generator.generate(["7.1", "8.0"])
|
|
30
|
+
# generator.eval_path_for("7.1.6") #=> "modular/activerecord_support/v7.1.gemfile"
|
|
25
31
|
class ActiveRecordSupportGemfileGenerator
|
|
26
32
|
# @return [String] header marking a support gemfile as generated (and safe to regenerate)
|
|
27
33
|
GENERATED_HEADER = "# Generated by kettle-jem-appraisals"
|
|
28
34
|
|
|
29
|
-
# @return [String]
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# @return [String] support gemfile for ActiveRecord at or above {MODERN_ACTIVERECORD}
|
|
33
|
-
MODERN_PATH = "gemfiles/modular/activerecord_support_modern.gemfile"
|
|
35
|
+
# @return [String] directory holding the support gemfiles
|
|
36
|
+
SUPPORT_DIR = "gemfiles/modular/activerecord_support"
|
|
34
37
|
|
|
35
|
-
# @return [
|
|
36
|
-
|
|
38
|
+
# @return [String] support gemfile for ActiveRecord older than every recorded series
|
|
39
|
+
UNKNOWN_PATH = "#{SUPPORT_DIR}/unknown.gemfile"
|
|
37
40
|
|
|
38
|
-
# @return [
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
# @return [Array<String>] support gemfiles written by kettle-jem-appraisals 0.1.4 and earlier
|
|
42
|
+
LEGACY_PATHS = [
|
|
43
|
+
"gemfiles/modular/activerecord_support.gemfile",
|
|
44
|
+
"gemfiles/modular/activerecord_support_modern.gemfile"
|
|
45
|
+
].freeze
|
|
43
46
|
|
|
44
47
|
class << self
|
|
45
48
|
# @param path [String] a gemfile path, with or without the leading +gemfiles/+
|
|
46
|
-
# @return [Boolean] whether +path+ names
|
|
49
|
+
# @return [Boolean] whether +path+ names an ActiveRecord support gemfile
|
|
47
50
|
def support_gemfile?(path)
|
|
48
51
|
normalized = path.to_s.strip.sub(%r{\Agemfiles/}, "")
|
|
49
|
-
|
|
52
|
+
return true if normalized.start_with?("#{SUPPORT_DIR.delete_prefix("gemfiles/")}/")
|
|
53
|
+
|
|
54
|
+
LEGACY_PATHS.any? { |legacy| legacy.delete_prefix("gemfiles/") == normalized }
|
|
50
55
|
end
|
|
51
56
|
end
|
|
52
57
|
|
|
@@ -58,40 +63,121 @@ module Kettle
|
|
|
58
63
|
@base_dir = base_dir
|
|
59
64
|
end
|
|
60
65
|
|
|
61
|
-
# Writes
|
|
66
|
+
# Writes the support gemfile needed by each ActiveRecord version, skipping
|
|
67
|
+
# hand-maintained files, and removes support gemfiles generated by
|
|
68
|
+
# kettle-jem-appraisals 0.1.4 and earlier.
|
|
62
69
|
#
|
|
63
|
-
# @
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
# @param activerecord_versions [Array<String>] ActiveRecord versions in the matrix
|
|
71
|
+
# @return [Hash{String => Symbol}] relative path => +:written+, +:kept+, or +:removed+
|
|
72
|
+
def generate(activerecord_versions)
|
|
73
|
+
results = {}
|
|
74
|
+
activerecord_versions.map { |version| group_for(version) }.uniq.each do |group|
|
|
75
|
+
relative_path = path_for(group)
|
|
66
76
|
absolute = File.join(base_dir, relative_path)
|
|
67
|
-
if
|
|
77
|
+
if hand_maintained?(absolute)
|
|
68
78
|
results[relative_path] = :kept
|
|
69
79
|
next
|
|
70
80
|
end
|
|
71
81
|
|
|
72
82
|
FileUtils.mkdir_p(File.dirname(absolute))
|
|
73
|
-
File.write(absolute, build_content(
|
|
83
|
+
File.write(absolute, build_content(group))
|
|
74
84
|
results[relative_path] = :written
|
|
75
85
|
end
|
|
86
|
+
|
|
87
|
+
LEGACY_PATHS.each do |relative_path|
|
|
88
|
+
absolute = File.join(base_dir, relative_path)
|
|
89
|
+
next unless File.exist?(absolute) && !hand_maintained?(absolute)
|
|
90
|
+
|
|
91
|
+
FileUtils.rm_f(absolute)
|
|
92
|
+
results[relative_path] = :removed
|
|
93
|
+
end
|
|
94
|
+
results
|
|
76
95
|
end
|
|
77
96
|
|
|
78
97
|
# @param activerecord_version [String, Gem::Version] the entry's ActiveRecord version
|
|
79
98
|
# @return [String] the matching support gemfile, relative to +gemfiles/+ (for +eval_gemfile+)
|
|
80
99
|
def eval_path_for(activerecord_version)
|
|
81
|
-
|
|
82
|
-
|
|
100
|
+
path_for(group_for(activerecord_version)).delete_prefix("gemfiles/")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param activerecord_version [String, Gem::Version]
|
|
104
|
+
# @return [Array<String>] the sqlite3 requirements for that ActiveRecord version
|
|
105
|
+
def sqlite3_requirements(activerecord_version)
|
|
106
|
+
group = group_for(activerecord_version)
|
|
107
|
+
group ? group.first.requirements : []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Advisories that affect every sqlite3 series the requirements allow.
|
|
111
|
+
#
|
|
112
|
+
# @param activerecord_version [String, Gem::Version]
|
|
113
|
+
# @return [Array<String>] advisory ids; empty when a fixed sqlite3 release is allowed
|
|
114
|
+
def unfixed_sqlite3_advisories(activerecord_version)
|
|
115
|
+
requirement = Gem::Requirement.new(*sqlite3_requirements(activerecord_version))
|
|
116
|
+
allowed = Kettle::Rb::GemFloors.entries_for("sqlite3").select { |entry|
|
|
117
|
+
requirement.satisfied_by?(Gem::Version.new(entry.floor))
|
|
118
|
+
}
|
|
119
|
+
return [] if allowed.empty? || allowed.any? { |entry| entry.unpatched_advisories.empty? }
|
|
120
|
+
|
|
121
|
+
allowed.flat_map(&:unpatched_advisories).uniq.sort
|
|
83
122
|
end
|
|
84
123
|
|
|
85
124
|
private
|
|
86
125
|
|
|
87
|
-
|
|
88
|
-
|
|
126
|
+
# Runs of consecutive ActiveRecord series with identical sqlite3 requirements.
|
|
127
|
+
def groups
|
|
128
|
+
@groups ||= Kettle::Rb::GemDependencies.entries_for("activerecord", "sqlite3")
|
|
129
|
+
.sort_by { |entry| Gem::Version.new(entry.series) }
|
|
130
|
+
.chunk_while { |older, newer| older.requirements == newer.requirements }
|
|
131
|
+
.to_a
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def group_for(activerecord_version)
|
|
135
|
+
series = Kettle::Rb::GemFloors.series_for(activerecord_version)
|
|
136
|
+
return unless series
|
|
137
|
+
|
|
138
|
+
version = Gem::Version.new(series)
|
|
139
|
+
groups.reverse.find { |group| Gem::Version.new(group.first.series) <= version }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def path_for(group)
|
|
143
|
+
group ? "#{SUPPORT_DIR}/v#{group.first.series}.gemfile" : UNKNOWN_PATH
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def hand_maintained?(absolute)
|
|
147
|
+
File.exist?(absolute) && !File.read(absolute).include?(GENERATED_HEADER)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def label(group)
|
|
151
|
+
return "ActiveRecord without recorded sqlite3 requirements" unless group
|
|
152
|
+
|
|
153
|
+
first = group.first.series
|
|
154
|
+
last = group.last.series
|
|
155
|
+
range =
|
|
156
|
+
if group.equal?(groups.last)
|
|
157
|
+
"#{first}+"
|
|
158
|
+
elsif first == last
|
|
159
|
+
first
|
|
160
|
+
else
|
|
161
|
+
"#{first}–#{last}"
|
|
162
|
+
end
|
|
163
|
+
"ActiveRecord #{range}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_content(group)
|
|
167
|
+
requirements = group ? group.first.requirements : []
|
|
168
|
+
sqlite3 = [%("sqlite3"), *requirements.map(&:inspect), "platforms: [:ruby]"].join(", ")
|
|
169
|
+
notes = []
|
|
170
|
+
notes << "# sqlite3 requirement from kettle-rb: #{group.last.source}" if group
|
|
171
|
+
unfixed = group ? unfixed_sqlite3_advisories(group.first.series) : []
|
|
172
|
+
notes << "# kettle-rb: no sqlite3 release allowed here fixes #{unfixed.join(", ")}" unless unfixed.empty?
|
|
173
|
+
|
|
89
174
|
<<~GEMFILE
|
|
90
175
|
# frozen_string_literal: true
|
|
91
176
|
|
|
92
177
|
#{GENERATED_HEADER}
|
|
93
|
-
# Database drivers for #{
|
|
178
|
+
# Database drivers for #{label(group)}: sqlite3 on MRI/TruffleRuby,
|
|
94
179
|
# activerecord-jdbcsqlite3-adapter on JRuby (its release series tracks ActiveRecord).
|
|
180
|
+
#{notes.join("\n")}
|
|
95
181
|
gem #{sqlite3}
|
|
96
182
|
|
|
97
183
|
platforms :jruby do
|
|
@@ -59,10 +59,49 @@ module Kettle
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# @return [Array<String>] command-line options accepted by {#run}
|
|
63
|
+
OPTIONS = %w[--scaffold --resolve --force -v --version -h --help].freeze
|
|
64
|
+
|
|
65
|
+
# @return [String] usage text printed by +--help+
|
|
66
|
+
USAGE = <<~USAGE
|
|
67
|
+
Usage: kettle-jem-appraisals [--scaffold | --resolve] [--force]
|
|
68
|
+
|
|
69
|
+
Generates an Appraisal matrix for the gem in the current directory.
|
|
70
|
+
Without a mode flag, scaffolds appraisal_matrix in #{CONFIG_FILE} when it
|
|
71
|
+
has no resolved versions, and resolves it otherwise.
|
|
72
|
+
|
|
73
|
+
Options:
|
|
74
|
+
--scaffold Force scaffold mode (even if config exists)
|
|
75
|
+
--resolve Force resolve mode
|
|
76
|
+
--force Bypass freshness TTL and re-resolve
|
|
77
|
+
-v, --version Print the version and exit
|
|
78
|
+
-h, --help Print this help and exit
|
|
79
|
+
USAGE
|
|
80
|
+
|
|
62
81
|
# Detects the appropriate mode and dispatches to scaffold or resolve.
|
|
63
82
|
#
|
|
83
|
+
# Prints usage for +-h+/+--help+ and the version for +-v+/+--version+
|
|
84
|
+
# without touching any files, and exits when given an unknown option.
|
|
85
|
+
#
|
|
64
86
|
# @return [void]
|
|
65
87
|
def run
|
|
88
|
+
if args.include?("-h") || args.include?("--help")
|
|
89
|
+
puts USAGE
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if args.include?("-v") || args.include?("--version")
|
|
94
|
+
puts Version::VERSION
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
unknown = args - OPTIONS
|
|
99
|
+
unless unknown.empty?
|
|
100
|
+
warn "Unknown option(s): #{unknown.join(" ")}"
|
|
101
|
+
warn USAGE
|
|
102
|
+
exit(1)
|
|
103
|
+
end
|
|
104
|
+
|
|
66
105
|
mode = detect_mode
|
|
67
106
|
case mode
|
|
68
107
|
when :scaffold
|
|
@@ -267,6 +306,7 @@ module Kettle
|
|
|
267
306
|
)
|
|
268
307
|
annotate_extra_gemfiles(appraisal_entries, matrix_extra_gemfiles(matrix))
|
|
269
308
|
annotate_activerecord_support(appraisal_entries, tier1_gems, matrix)
|
|
309
|
+
warn_security_advisories(appraisal_entries)
|
|
270
310
|
annotate_standard_appraisal_collapses(appraisal_entries, bucket_ranges, matrix)
|
|
271
311
|
|
|
272
312
|
puts " 📊 Generated #{appraisal_entries.size} appraisal entries"
|
|
@@ -295,19 +335,27 @@ module Kettle
|
|
|
295
335
|
config[APPRAISAL_MATRIX_KEY] = matrix
|
|
296
336
|
write_config(config)
|
|
297
337
|
|
|
298
|
-
|
|
299
|
-
if File.exist?(File.join(project_dir, "bin", "appraisal"))
|
|
300
|
-
puts " 🔧 Running bin/appraisal generate..."
|
|
301
|
-
system("bin/appraisal", "generate", chdir: project_dir)
|
|
302
|
-
else
|
|
303
|
-
puts " ⚠️ bin/appraisal not found — run `bundle binstubs appraisal2` then `bin/appraisal generate`"
|
|
304
|
-
end
|
|
338
|
+
run_appraisal_generate
|
|
305
339
|
|
|
306
340
|
puts " ✅ Resolve complete"
|
|
307
341
|
end
|
|
308
342
|
|
|
309
343
|
# ── Helpers ──────────────────────────────────────────────────────
|
|
310
344
|
|
|
345
|
+
# Runs +bin/appraisal generate+ when the binstub exists, exiting when it fails.
|
|
346
|
+
def run_appraisal_generate
|
|
347
|
+
unless File.exist?(File.join(project_dir, "bin", "appraisal"))
|
|
348
|
+
puts " ⚠️ bin/appraisal not found — run `bundle binstubs appraisal2` then `bin/appraisal generate`"
|
|
349
|
+
return
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
puts " 🔧 Running bin/appraisal generate..."
|
|
353
|
+
return if system("bin/appraisal", "generate", chdir: project_dir)
|
|
354
|
+
|
|
355
|
+
warn " ❌ bin/appraisal generate failed (#{$?.inspect}); Appraisals and gemfiles/ may be incomplete"
|
|
356
|
+
exit(1)
|
|
357
|
+
end
|
|
358
|
+
|
|
311
359
|
# Removes stale kja-* flat gemfiles from gemfiles/ that are no longer
|
|
312
360
|
# in the current matrix. Only touches files matching the PREFIX pattern.
|
|
313
361
|
def cleanup_stale_gemfiles(current_entries)
|
|
@@ -393,6 +441,14 @@ module Kettle
|
|
|
393
441
|
# that version is the best choice).
|
|
394
442
|
#
|
|
395
443
|
# Tier2 versions are all compatible versions for each tier1's bucket.
|
|
444
|
+
# Reports selected versions that could not be assigned to any Ruby bucket.
|
|
445
|
+
def warn_unassigned_versions(gem_name, versions, assignments)
|
|
446
|
+
missing = versions - assignments.map { |assignment| assignment[:version] }
|
|
447
|
+
return if missing.empty?
|
|
448
|
+
|
|
449
|
+
warn " ⚠️ #{gem_name} #{missing.join(", ")} not assigned to any Ruby bucket (unknown required_ruby_version?); left out of the matrix"
|
|
450
|
+
end
|
|
451
|
+
|
|
396
452
|
def build_matrix(tier1_gems, tier2_gems, ruby_series, bucket_ranges, all_seams, all_versions_by_gem, resolver, builder, gemfile_gen, sub_resolver)
|
|
397
453
|
entries = []
|
|
398
454
|
|
|
@@ -415,6 +471,7 @@ module Kettle
|
|
|
415
471
|
puts " ⚠️ No bucket assignments for #{t1_name}, falling back to latest bucket"
|
|
416
472
|
t1_assignments = t1_versions.map { |v| {version: v, bucket: ruby_series.last} }
|
|
417
473
|
end
|
|
474
|
+
warn_unassigned_versions(t1_name, t1_versions, t1_assignments)
|
|
418
475
|
|
|
419
476
|
# Show assignments
|
|
420
477
|
t1_assignments.each do |a|
|
|
@@ -609,8 +666,9 @@ module Kettle
|
|
|
609
666
|
end.uniq
|
|
610
667
|
end
|
|
611
668
|
|
|
612
|
-
# Generates the ActiveRecord database support gemfiles
|
|
613
|
-
# matching each ActiveRecord entry's version to its extra gemfiles.
|
|
669
|
+
# Generates the ActiveRecord database support gemfiles the matrix needs and
|
|
670
|
+
# adds the one matching each ActiveRecord entry's version to its extra gemfiles.
|
|
671
|
+
# Warns when no sqlite3 release an entry's support gemfile allows fixes an advisory.
|
|
614
672
|
# Skipped when disabled via +activerecord_support+, when activerecord is not
|
|
615
673
|
# a tier1 gem, or when +appraisal_gemfiles+ already names a support gemfile.
|
|
616
674
|
def annotate_activerecord_support(appraisal_entries, tier1_gems, matrix)
|
|
@@ -618,16 +676,43 @@ module Kettle
|
|
|
618
676
|
return unless tier1_gems.any? { |gem_config| gem_config["name"] == "activerecord" }
|
|
619
677
|
return if matrix_extra_gemfiles(matrix).any? { |path| ActiveRecordSupportGemfileGenerator.support_gemfile?(path) }
|
|
620
678
|
|
|
679
|
+
activerecord_entries = appraisal_entries.select { |entry| entry[:tier1_name] == "activerecord" && entry[:tier1_version] }
|
|
680
|
+
versions = activerecord_entries.map { |entry| entry[:tier1_version] }
|
|
621
681
|
generator = ActiveRecordSupportGemfileGenerator.new(base_dir: project_dir)
|
|
622
|
-
generator.generate.each do |path, status|
|
|
623
|
-
|
|
682
|
+
generator.generate(versions).each do |path, status|
|
|
683
|
+
label = {written: "Wrote", kept: "Kept hand-maintained", removed: "Removed stale generated"}.fetch(status)
|
|
684
|
+
puts " 🗄️ #{label} #{path}"
|
|
624
685
|
end
|
|
625
|
-
|
|
626
|
-
next unless entry[:tier1_name] == "activerecord" && entry[:tier1_version]
|
|
627
|
-
|
|
686
|
+
activerecord_entries.each do |entry|
|
|
628
687
|
support = generator.eval_path_for(entry[:tier1_version])
|
|
629
688
|
entry[:extra_gemfiles] = [support, *Array(entry[:extra_gemfiles])].uniq
|
|
630
689
|
end
|
|
690
|
+
versions.group_by { |version| generator.eval_path_for(version) }.each do |path, grouped|
|
|
691
|
+
unfixed = generator.unfixed_sqlite3_advisories(grouped.first)
|
|
692
|
+
next if unfixed.empty?
|
|
693
|
+
|
|
694
|
+
warn " ⚠️ kettle-rb: no sqlite3 release allowed for activerecord #{grouped.uniq.join(", ")} (#{path}) fixes #{unfixed.join(", ")}"
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Warns about tracked gem versions in the matrix whose minor series has
|
|
699
|
+
# advisories no release fixes, or whose exact patch version is below the
|
|
700
|
+
# series' security floor (from +Kettle::Rb::GemFloors+).
|
|
701
|
+
def warn_security_advisories(appraisal_entries)
|
|
702
|
+
pairs = appraisal_entries.flat_map { |entry|
|
|
703
|
+
[[entry[:tier1_name], entry[:tier1_version]], [entry[:tier2_name], entry[:tier2_version]]]
|
|
704
|
+
}
|
|
705
|
+
messages = pairs.filter_map { |name, version| [name, version] if name && version && Kettle::Rb::GemFloors.tracked?(name) }.flat_map { |name, version|
|
|
706
|
+
notes = []
|
|
707
|
+
unpatched = Kettle::Rb::GemFloors.unpatched_advisories(name, version)
|
|
708
|
+
notes << "no #{name} #{Kettle::Rb::GemFloors.series_for(version)} release fixes #{unpatched.join(", ")}" unless unpatched.empty?
|
|
709
|
+
floor = Kettle::Rb::GemFloors.floor(name, version)
|
|
710
|
+
if floor && version.to_s.split(".").length >= 3 && Gem::Version.new(floor) > Gem::Version.new(version.to_s)
|
|
711
|
+
notes << "#{name} #{version} is pinned below the security floor #{floor}"
|
|
712
|
+
end
|
|
713
|
+
notes
|
|
714
|
+
}
|
|
715
|
+
messages.uniq.each { |message| warn " ⚠️ kettle-rb: #{message}" }
|
|
631
716
|
end
|
|
632
717
|
|
|
633
718
|
def activerecord_support_enabled?(matrix)
|
|
@@ -115,7 +115,11 @@ module Kettle
|
|
|
115
115
|
next_seam_ruby = find_next_seam_ruby(ver, ver_min_ruby, all_versions, version_min_ruby)
|
|
116
116
|
|
|
117
117
|
bucket = if next_seam_ruby
|
|
118
|
-
|
|
118
|
+
# When the next seam is at or below the project's Ruby floor, no
|
|
119
|
+
# bucket sits below it; test this version on the lowest bucket
|
|
120
|
+
# that can run it instead of dropping it.
|
|
121
|
+
find_bucket_below(next_seam_ruby, buckets, bucket_ranges) ||
|
|
122
|
+
find_lowest_runnable_bucket(ver_min_ruby, buckets, bucket_ranges)
|
|
119
123
|
else
|
|
120
124
|
# This is in the latest seam range — catch-all bucket
|
|
121
125
|
buckets.last
|
|
@@ -130,6 +134,14 @@ module Kettle
|
|
|
130
134
|
|
|
131
135
|
private
|
|
132
136
|
|
|
137
|
+
# Finds the lowest bucket whose Ruby ceiling can run a version requiring +min_ruby+.
|
|
138
|
+
def find_lowest_runnable_bucket(min_ruby, buckets, bucket_ranges)
|
|
139
|
+
buckets.find { |bucket|
|
|
140
|
+
range = bucket_ranges[bucket]
|
|
141
|
+
range && range[:ceiling] >= min_ruby
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
133
145
|
# One entry per major version (the latest minor of each).
|
|
134
146
|
def select_major(by_major)
|
|
135
147
|
by_major.map { |entry| entry[:minors].last }
|
|
@@ -190,6 +202,9 @@ module Kettle
|
|
|
190
202
|
end
|
|
191
203
|
end
|
|
192
204
|
|
|
205
|
+
# Explicit requirements declare the oldest supported version, so test it.
|
|
206
|
+
versions << by_major.first[:minors].first if requirements && !requirements.empty?
|
|
207
|
+
|
|
193
208
|
versions.uniq.sort_by { |v| Gem::Version.new(v) }
|
|
194
209
|
end
|
|
195
210
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "kettle/rb"
|
|
4
|
+
|
|
3
5
|
module Kettle
|
|
4
6
|
module Jem
|
|
5
7
|
module Appraisals
|
|
@@ -8,6 +10,11 @@ module Kettle
|
|
|
8
10
|
# Creates per-version gemfiles organized by gem name and Ruby series:
|
|
9
11
|
# +gemfiles/modular/{gem_name}/{ruby_series}/v{version}.gemfile+
|
|
10
12
|
#
|
|
13
|
+
# Gems tracked by +Kettle::Rb::GemFloors+ get their minor series' security
|
|
14
|
+
# floor (e.g. +"~> 7.1.0", ">= 7.1.6"+). Exact patch versions stay pinned.
|
|
15
|
+
# A comment notes advisories that no release in the series fixes, and an
|
|
16
|
+
# exact patch version below the floor.
|
|
17
|
+
#
|
|
11
18
|
# @example
|
|
12
19
|
# gen = ModularGemfileGenerator.new(base_dir: "/path/to/project")
|
|
13
20
|
# gen.generate(gem_name: "activerecord", version: "7.1", ruby_series: "r3")
|
|
@@ -66,15 +73,11 @@ module Kettle
|
|
|
66
73
|
private
|
|
67
74
|
|
|
68
75
|
def build_content(gem_name, version, sub_deps)
|
|
69
|
-
lines =
|
|
70
|
-
|
|
71
|
-
"",
|
|
72
|
-
"# Generated by kettle-jem-appraisals",
|
|
73
|
-
"gem #{gem_name.inspect}, #{version_requirement(version).inspect}"
|
|
74
|
-
]
|
|
76
|
+
lines = header_lines
|
|
77
|
+
lines.concat(gem_lines(gem_name, version, pinned_requirements(gem_name, version)))
|
|
75
78
|
|
|
76
79
|
sub_deps.each do |dep_name, dep_version|
|
|
77
|
-
lines
|
|
80
|
+
lines.concat(gem_lines(dep_name, dep_version, floored_requirements(dep_name, dep_version, ["~> #{dep_version}"])))
|
|
78
81
|
end
|
|
79
82
|
|
|
80
83
|
lines << ""
|
|
@@ -82,19 +85,64 @@ module Kettle
|
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
def build_tier2_content(gem_name, version)
|
|
85
|
-
lines =
|
|
88
|
+
lines = header_lines
|
|
89
|
+
lines.concat(gem_lines(gem_name, version, pinned_requirements(gem_name, version)))
|
|
90
|
+
lines << ""
|
|
91
|
+
"#{lines.join("\n")}\n"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def header_lines
|
|
95
|
+
[
|
|
86
96
|
"# frozen_string_literal: true",
|
|
87
97
|
"",
|
|
88
|
-
"# Generated by kettle-jem-appraisals"
|
|
89
|
-
"gem #{gem_name.inspect}, #{version_requirement(version).inspect}",
|
|
90
|
-
""
|
|
98
|
+
"# Generated by kettle-jem-appraisals"
|
|
91
99
|
]
|
|
92
|
-
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def gem_lines(gem_name, version, requirements)
|
|
103
|
+
advisory_notes(gem_name, version, requirements) + ["gem #{[gem_name, *requirements].map(&:inspect).join(", ")}"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Minor versions get the series' security floor; exact patch versions stay pinned.
|
|
107
|
+
def pinned_requirements(gem_name, version)
|
|
108
|
+
requirement = version_requirement(version)
|
|
109
|
+
return [requirement] if exact_patch?(version)
|
|
110
|
+
|
|
111
|
+
floored_requirements(gem_name, version, [requirement])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def floored_requirements(gem_name, version, requirements)
|
|
115
|
+
floor = floor_above(gem_name, version)
|
|
116
|
+
floor ? requirements + [">= #{floor}"] : requirements
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def advisory_notes(gem_name, version, requirements)
|
|
120
|
+
notes = []
|
|
121
|
+
unpatched = Kettle::Rb::GemFloors.unpatched_advisories(gem_name, version)
|
|
122
|
+
unless unpatched.empty?
|
|
123
|
+
series = Kettle::Rb::GemFloors.series_for(version)
|
|
124
|
+
notes << "# kettle-rb: no #{gem_name} #{series} release fixes #{unpatched.join(", ")}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
floor = floor_above(gem_name, version)
|
|
128
|
+
if floor && !requirements.include?(">= #{floor}")
|
|
129
|
+
notes << "# kettle-rb: #{gem_name} #{version} is below the #{gem_name} security floor #{floor}"
|
|
130
|
+
end
|
|
131
|
+
notes
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [String, nil] the security floor when it is newer than +version+
|
|
135
|
+
def floor_above(gem_name, version)
|
|
136
|
+
floor = Kettle::Rb::GemFloors.floor(gem_name, version)
|
|
137
|
+
floor if floor && Gem::Version.new(floor) > Gem::Version.new(version)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def exact_patch?(version)
|
|
141
|
+
version.to_s.split(".").length >= 3
|
|
93
142
|
end
|
|
94
143
|
|
|
95
144
|
def version_requirement(version)
|
|
96
|
-
|
|
97
|
-
if segments.length >= 3
|
|
145
|
+
if exact_patch?(version)
|
|
98
146
|
"~> #{version}"
|
|
99
147
|
else
|
|
100
148
|
"~> #{version}.0"
|
|
@@ -6,7 +6,7 @@ module Kettle
|
|
|
6
6
|
# Version namespace for this gem.
|
|
7
7
|
module Version
|
|
8
8
|
# Current gem version.
|
|
9
|
-
VERSION = "0.1.
|
|
9
|
+
VERSION = "0.1.5"
|
|
10
10
|
end
|
|
11
11
|
# Current gem version exposed at the traditional constant location.
|
|
12
12
|
VERSION = Version::VERSION # Traditional Constant Location
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kettle-jem-appraisals
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Peter H. Boling
|
|
@@ -57,6 +57,26 @@ dependencies:
|
|
|
57
57
|
- - ">="
|
|
58
58
|
- !ruby/object:Gem::Version
|
|
59
59
|
version: 7.1.28
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: kettle-rb
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0.1'
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: 0.1.14
|
|
70
|
+
type: :runtime
|
|
71
|
+
prerelease: false
|
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - "~>"
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0.1'
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: 0.1.14
|
|
60
80
|
- !ruby/object:Gem::Dependency
|
|
61
81
|
name: version_gem
|
|
62
82
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -315,10 +335,10 @@ licenses:
|
|
|
315
335
|
- AGPL-3.0-only
|
|
316
336
|
metadata:
|
|
317
337
|
homepage_uri: https://kettle-jem-appraisals.galtzo.com
|
|
318
|
-
source_code_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/tree/v0.1.
|
|
319
|
-
changelog_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/blob/v0.1.
|
|
338
|
+
source_code_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/tree/v0.1.5
|
|
339
|
+
changelog_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/blob/v0.1.5/CHANGELOG.md
|
|
320
340
|
bug_tracker_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/issues
|
|
321
|
-
documentation_uri: https://www.rubydoc.info/gems/kettle-jem-appraisals/0.1.
|
|
341
|
+
documentation_uri: https://www.rubydoc.info/gems/kettle-jem-appraisals/0.1.5
|
|
322
342
|
funding_uri: https://github.com/sponsors/pboling
|
|
323
343
|
wiki_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/wiki
|
|
324
344
|
news_uri: https://www.railsbling.com/tags/kettle-jem-appraisals
|
metadata.gz.sig
CHANGED
|
Binary file
|