captive-release 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/captive-release +8 -0
- data/lib/captive_release/cli.rb +419 -0
- data/lib/captive_release/config.rb +73 -0
- data/lib/captive_release/git_commands.rb +47 -0
- data/lib/captive_release/github_dispatch.rb +40 -0
- data/lib/captive_release/github_http.rb +42 -0
- data/lib/captive_release/github_release.rb +44 -0
- data/lib/captive_release/version.rb +5 -0
- data/lib/captive_release/version_bumper.rb +29 -0
- data/lib/captive_release/version_detector.rb +83 -0
- data/lib/captive_release/version_file.rb +107 -0
- data/lib/captive_release.rb +14 -0
- metadata +54 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 62ba2157fbf2d4c10b034accf3e73b2de0d69018462070b8e2103db59b23ad5f
|
|
4
|
+
data.tar.gz: 9851621f86185f00f08194435d3c813bcd7db0782fe3f8475449284dad8b166c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6290d340d5d2ae97b3f110ba8e03b27f295da458608110f6f5db710766856ccd691efb47d2a4f4505987a4a69035981618c2b21986d205a26ee56d1817b1a3ba
|
|
7
|
+
data.tar.gz: c5f6a79bd4ee364c862475939696187ef8dcc7e38ce908c053a8d7634a15381cb827b50d02b7864fb2512f952ca124354fbbeae25bee5f320df176fd36f575b3
|
data/bin/captive-release
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
class CLI
|
|
5
|
+
BUMP_TYPES = %w[patch minor major].freeze
|
|
6
|
+
SEMVER_RE = /\A\d+\.\d+\.\d+\z/
|
|
7
|
+
|
|
8
|
+
def self.run(argv = ARGV)
|
|
9
|
+
new(argv.dup).run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(argv)
|
|
13
|
+
@argv = argv
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
command = @argv.shift
|
|
18
|
+
|
|
19
|
+
case command
|
|
20
|
+
when "init" then run_init
|
|
21
|
+
when "new" then run_release
|
|
22
|
+
when "build" then run_build
|
|
23
|
+
when "version", "--version", "-v" then puts "captive-release #{VERSION}"
|
|
24
|
+
when "help", "--help", "-h", nil then print_help
|
|
25
|
+
else
|
|
26
|
+
puts error("Unknown command: #{command}")
|
|
27
|
+
print_help
|
|
28
|
+
exit 1
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# ── init ────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def run_init
|
|
37
|
+
files_arg = extract_option("--files")
|
|
38
|
+
|
|
39
|
+
if files_arg
|
|
40
|
+
run_init_non_interactive(files_arg)
|
|
41
|
+
else
|
|
42
|
+
run_init_interactive
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run_init_interactive
|
|
47
|
+
puts "\n#{bold("captive-release init")}\n\n"
|
|
48
|
+
puts "Scanning for version files...\n\n"
|
|
49
|
+
|
|
50
|
+
found = VersionDetector.scan
|
|
51
|
+
|
|
52
|
+
if found.empty?
|
|
53
|
+
puts warn("No version files found (package.json, VERSION).")
|
|
54
|
+
create_version_file_if_confirmed
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
found.each_with_index do |f, i|
|
|
59
|
+
version = safe_read(f)
|
|
60
|
+
puts " #{dim("[#{i + 1}]")} #{f.path} #{dim("(#{version})")}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
selected = if found.size == 1
|
|
64
|
+
puts "\n#{ok("✅")} 1 fichier détecté — sélectionné automatiquement."
|
|
65
|
+
found
|
|
66
|
+
else
|
|
67
|
+
puts "\nAppuyez sur #{bold("Entrée")} pour tout sélectionner, ou entrez les numéros à #{bold("exclure")} (ex: 2,3) :"
|
|
68
|
+
print "> "
|
|
69
|
+
input = $stdin.gets&.chomp || ""
|
|
70
|
+
if input.strip.empty?
|
|
71
|
+
found
|
|
72
|
+
else
|
|
73
|
+
excluded = input.split(",").map { |n| n.strip.to_i - 1 }
|
|
74
|
+
found.reject.with_index { |_, i| excluded.include?(i) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if selected.empty?
|
|
79
|
+
puts warn("Aucun fichier sélectionné.")
|
|
80
|
+
create_version_file_if_confirmed
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
write_config(selected)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def create_version_file_if_confirmed
|
|
88
|
+
puts "\nCreate a #{bold("VERSION")} file? [y/N] "
|
|
89
|
+
print "> "
|
|
90
|
+
answer = $stdin.gets&.chomp&.downcase || ""
|
|
91
|
+
exit 1 unless answer == "y"
|
|
92
|
+
|
|
93
|
+
initial = "0.1.0"
|
|
94
|
+
File.write("VERSION", "#{initial}\n")
|
|
95
|
+
puts " #{ok("✅")} VERSION créé avec #{bold(initial)}"
|
|
96
|
+
|
|
97
|
+
file = VersionFile.new("VERSION", "plain")
|
|
98
|
+
write_config([file])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def run_init_non_interactive(files_arg)
|
|
102
|
+
entries = files_arg.split(",").map(&:strip)
|
|
103
|
+
selected = entries.map do |entry|
|
|
104
|
+
path, type = entry.split(":")
|
|
105
|
+
type ||= detect_type(path)
|
|
106
|
+
VersionFile.new(path, type)
|
|
107
|
+
end
|
|
108
|
+
write_config(selected)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_config(files, expo: nil, capacitor: nil)
|
|
112
|
+
if files.empty?
|
|
113
|
+
puts error("No files selected.")
|
|
114
|
+
exit 1
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
detector = VersionDetector.new(".")
|
|
118
|
+
expo ||= ask_expo_config if detector.expo?
|
|
119
|
+
capacitor ||= ask_capacitor_config if detector.capacitor?
|
|
120
|
+
|
|
121
|
+
Config.write(files, expo: expo, capacitor: capacitor)
|
|
122
|
+
puts "\n#{ok("✅ captive-release.json written with #{files.size} version file(s).")}"
|
|
123
|
+
puts " #{dim("Expo build workflow: #{expo.build_workflow}")}" if expo
|
|
124
|
+
puts " #{dim("Capacitor build workflow: #{capacitor.build_workflow}")}" if capacitor
|
|
125
|
+
puts
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ask_expo_config
|
|
129
|
+
puts "\n#{bold("Expo project detected.")} Configure EAS build dispatch?\n"
|
|
130
|
+
puts " #{dim("1)")} Yes"
|
|
131
|
+
puts " #{dim("2)")} No"
|
|
132
|
+
print "\n> "
|
|
133
|
+
choice = $stdin.gets&.chomp&.to_i || 0
|
|
134
|
+
return nil unless choice == 1
|
|
135
|
+
|
|
136
|
+
puts "\nWorkflow file name #{dim("(default: build.yml)")}:"
|
|
137
|
+
print "> "
|
|
138
|
+
workflow = $stdin.gets&.chomp || ""
|
|
139
|
+
workflow = "build.yml" if workflow.empty?
|
|
140
|
+
|
|
141
|
+
ExpoConfig.new(build_workflow: workflow)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def ask_capacitor_config
|
|
145
|
+
puts "\n#{bold("Capacitor project detected.")} Configure build dispatch?\n"
|
|
146
|
+
puts " #{dim("1)")} Yes"
|
|
147
|
+
puts " #{dim("2)")} No"
|
|
148
|
+
print "\n> "
|
|
149
|
+
choice = $stdin.gets&.chomp&.to_i || 0
|
|
150
|
+
return nil unless choice == 1
|
|
151
|
+
|
|
152
|
+
puts "\nWorkflow file name #{dim("(default: capacitor-build.yml)")}:"
|
|
153
|
+
print "> "
|
|
154
|
+
workflow = $stdin.gets&.chomp || ""
|
|
155
|
+
workflow = "capacitor-build.yml" if workflow.empty?
|
|
156
|
+
|
|
157
|
+
CapacitorConfig.new(build_workflow: workflow)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def parse_selection(input, found)
|
|
161
|
+
input.split(",").map { |n| found[n.strip.to_i - 1] }.compact
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def detect_type(path)
|
|
165
|
+
File.basename(path) == "package.json" ? "package_json" : "plain"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# ── release ─────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def run_release
|
|
171
|
+
bump_arg = @argv.shift
|
|
172
|
+
config = load_config
|
|
173
|
+
|
|
174
|
+
current = config.current_version
|
|
175
|
+
puts "\n#{bold("captive-release")}\n\n"
|
|
176
|
+
puts "Current version: #{bold(current)}\n\n"
|
|
177
|
+
|
|
178
|
+
next_version =
|
|
179
|
+
if bump_arg
|
|
180
|
+
resolve_version(current, bump_arg)
|
|
181
|
+
else
|
|
182
|
+
ask_version(current)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
confirm!(next_version) unless bump_arg
|
|
186
|
+
|
|
187
|
+
puts "\n#{bold("Updating version files...")}"
|
|
188
|
+
config.version_files.each do |f|
|
|
189
|
+
f.write_version(next_version)
|
|
190
|
+
puts " #{ok("✅")} #{f.path}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
puts "\n#{bold("Running git commands...")}"
|
|
194
|
+
last_tag = GitCommands.last_tag
|
|
195
|
+
GitCommands.add_and_commit(next_version, config.version_files.map(&:path))
|
|
196
|
+
puts " #{ok("✅")} git commit \"🔖 Set version v#{next_version}\""
|
|
197
|
+
GitCommands.tag(next_version)
|
|
198
|
+
puts " #{ok("✅")} git tag v#{next_version}"
|
|
199
|
+
GitCommands.push(next_version)
|
|
200
|
+
puts " #{ok("✅")} git push + tag"
|
|
201
|
+
|
|
202
|
+
puts "\n#{bold("Creating GitHub release...")}"
|
|
203
|
+
token = github_token
|
|
204
|
+
if token
|
|
205
|
+
commits = GitCommands.commits_since(last_tag)
|
|
206
|
+
url = GithubRelease.create(
|
|
207
|
+
version: next_version,
|
|
208
|
+
body: commits,
|
|
209
|
+
remote_url: GitCommands.remote_url,
|
|
210
|
+
token: token
|
|
211
|
+
)
|
|
212
|
+
puts " #{ok("✅")} #{url}"
|
|
213
|
+
else
|
|
214
|
+
puts " #{warn("⚠️ GITHUB_TOKEN not set — skipping GitHub release creation.")}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
puts "\n🎉 #{bold("Version v#{next_version} released successfully!")}\n\n"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def resolve_version(current, type)
|
|
221
|
+
unless type.match?(SEMVER_RE) || BUMP_TYPES.include?(type)
|
|
222
|
+
puts error("Invalid bump type: #{type}. Use patch|minor|major|x.y.z")
|
|
223
|
+
exit 1
|
|
224
|
+
end
|
|
225
|
+
VersionBumper.bump(current, type)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def ask_version(current)
|
|
229
|
+
candidates = {
|
|
230
|
+
"Patch" => VersionBumper.bump(current, "patch"),
|
|
231
|
+
"Minor" => VersionBumper.bump(current, "minor"),
|
|
232
|
+
"Major" => VersionBumper.bump(current, "major"),
|
|
233
|
+
"Custom" => nil
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
puts "Choose release type:\n"
|
|
237
|
+
candidates.each_with_index do |(label, version), i|
|
|
238
|
+
preview = version ? "→ #{bold(version)}" : "→ ?"
|
|
239
|
+
puts " #{dim("#{i + 1})")} #{label} #{preview}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
print "\n> "
|
|
243
|
+
choice = $stdin.gets&.chomp&.to_i || 0
|
|
244
|
+
|
|
245
|
+
labels = candidates.keys
|
|
246
|
+
chosen = labels[choice - 1]
|
|
247
|
+
|
|
248
|
+
if chosen.nil?
|
|
249
|
+
puts error("Invalid choice.")
|
|
250
|
+
exit 1
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if chosen == "Custom"
|
|
254
|
+
print "Enter version (x.y.z): "
|
|
255
|
+
version = $stdin.gets&.chomp || ""
|
|
256
|
+
unless version.match?(SEMVER_RE)
|
|
257
|
+
puts error("Invalid semver: #{version}")
|
|
258
|
+
exit 1
|
|
259
|
+
end
|
|
260
|
+
version
|
|
261
|
+
else
|
|
262
|
+
candidates[chosen]
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def confirm!(version)
|
|
267
|
+
puts "\nNew version: #{bold(version)}"
|
|
268
|
+
print "Confirm? [y/N] "
|
|
269
|
+
answer = $stdin.gets&.chomp&.downcase || ""
|
|
270
|
+
exit 1 unless answer == "y"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ── build ────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
def run_build
|
|
276
|
+
profile_arg = extract_option("--profile")
|
|
277
|
+
|
|
278
|
+
config = load_config
|
|
279
|
+
|
|
280
|
+
unless config.expo? || config.capacitor?
|
|
281
|
+
puts error("Aucune configuration de build dans #{CONFIG_FILE}.")
|
|
282
|
+
puts "Lancez #{bold("captive-release init")} pour configurer le workflow de build.\n\n"
|
|
283
|
+
exit 1
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
puts "\n#{bold("captive-release build")}\n\n"
|
|
287
|
+
|
|
288
|
+
profile = profile_arg || ask_profile
|
|
289
|
+
|
|
290
|
+
token = github_token
|
|
291
|
+
unless token
|
|
292
|
+
puts error("GITHUB_TOKEN or `gh auth login` required to dispatch build workflows.")
|
|
293
|
+
exit 1
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
ref = current_branch
|
|
297
|
+
workflow_file = config.expo? ? config.expo.build_workflow : config.capacitor.build_workflow
|
|
298
|
+
|
|
299
|
+
puts "Profile: #{bold(profile)}"
|
|
300
|
+
puts "Workflow: #{bold(workflow_file)}"
|
|
301
|
+
puts "Branch: #{bold(ref)}"
|
|
302
|
+
puts "\nDispatching...\n"
|
|
303
|
+
|
|
304
|
+
url = GithubDispatch.dispatch(
|
|
305
|
+
workflow_file: workflow_file,
|
|
306
|
+
ref: ref,
|
|
307
|
+
inputs: { "profile" => profile },
|
|
308
|
+
remote_url: GitCommands.remote_url,
|
|
309
|
+
token: token
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
puts "#{ok("✅")} Build dispatched!"
|
|
313
|
+
puts " #{url}\n\n"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def ask_profile
|
|
317
|
+
puts "Choose profile:\n"
|
|
318
|
+
puts " #{dim("1)")} staging"
|
|
319
|
+
puts " #{dim("2)")} production"
|
|
320
|
+
print "\n> "
|
|
321
|
+
choice = $stdin.gets&.chomp&.to_i || 0
|
|
322
|
+
|
|
323
|
+
case choice
|
|
324
|
+
when 1 then "staging"
|
|
325
|
+
when 2 then "production"
|
|
326
|
+
else
|
|
327
|
+
puts error("Invalid choice.")
|
|
328
|
+
exit 1
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def current_branch
|
|
333
|
+
branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
|
|
334
|
+
branch.empty? ? "main" : branch
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# ── helpers ─────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
def load_config
|
|
340
|
+
return Config.load if File.exist?(CONFIG_FILE)
|
|
341
|
+
|
|
342
|
+
files = VersionDetector.scan
|
|
343
|
+
if files.empty?
|
|
344
|
+
puts error("Aucun fichier de version trouvé (package.json, lib/**/version.rb, VERSION).")
|
|
345
|
+
puts "Créez un fichier #{bold("VERSION")} ou lancez #{bold("captive-release init")}.\n\n"
|
|
346
|
+
exit 1
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
Config.new(files)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def github_token
|
|
353
|
+
return ENV["GITHUB_TOKEN"] unless ENV["GITHUB_TOKEN"].to_s.empty?
|
|
354
|
+
|
|
355
|
+
token = `gh auth token 2>/dev/null`.strip
|
|
356
|
+
token.empty? ? nil : token
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def safe_read(file)
|
|
360
|
+
file.read_version
|
|
361
|
+
rescue
|
|
362
|
+
"?"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def extract_option(flag)
|
|
366
|
+
idx = @argv.index { |a| a.start_with?("#{flag}=") || a == flag }
|
|
367
|
+
return nil if idx.nil?
|
|
368
|
+
|
|
369
|
+
arg = @argv.delete_at(idx)
|
|
370
|
+
if arg.include?("=")
|
|
371
|
+
arg.split("=", 2).last
|
|
372
|
+
else
|
|
373
|
+
@argv.delete_at(idx)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def print_help
|
|
378
|
+
puts <<~HELP
|
|
379
|
+
|
|
380
|
+
#{bold("captive-release")} — Semver release tool
|
|
381
|
+
|
|
382
|
+
#{bold("Usage:")}
|
|
383
|
+
captive-release init [--files path[:type],...]
|
|
384
|
+
captive-release new [patch|minor|major|x.y.z]
|
|
385
|
+
captive-release version
|
|
386
|
+
|
|
387
|
+
#{bold("Commands:")}
|
|
388
|
+
init Detect version files and write captive-release.json
|
|
389
|
+
new Bump version, commit, tag, push, create GitHub release
|
|
390
|
+
build Dispatch an EAS build workflow (staging/production) via GitHub Actions
|
|
391
|
+
|
|
392
|
+
#{bold("Options for init:")}
|
|
393
|
+
--files Non-interactive: comma-separated list of files with optional type
|
|
394
|
+
Type is auto-detected (package_json or plain) when omitted
|
|
395
|
+
Example: --files=package.json,ios/VERSION:plain
|
|
396
|
+
|
|
397
|
+
#{bold("Examples:")}
|
|
398
|
+
captive-release init
|
|
399
|
+
captive-release init --files=package.json,apps/mobile/package.json
|
|
400
|
+
captive-release new
|
|
401
|
+
captive-release new patch
|
|
402
|
+
captive-release new 2.0.0
|
|
403
|
+
captive-release build
|
|
404
|
+
captive-release build --profile staging
|
|
405
|
+
captive-release build --profile production
|
|
406
|
+
|
|
407
|
+
#{bold("GitHub release:")}
|
|
408
|
+
Set GITHUB_TOKEN env var or authenticate with `gh auth login`
|
|
409
|
+
|
|
410
|
+
HELP
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def bold(str) = "\e[1m#{str}\e[0m"
|
|
414
|
+
def dim(str) = "\e[2m#{str}\e[0m"
|
|
415
|
+
def ok(str) = "\e[32m#{str}\e[0m"
|
|
416
|
+
def warn(str) = "\e[33m#{str}\e[0m"
|
|
417
|
+
def error(str) = "\e[31m#{str}\e[0m"
|
|
418
|
+
end
|
|
419
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module CaptiveRelease
|
|
6
|
+
CONFIG_FILE = "captive-release.json"
|
|
7
|
+
|
|
8
|
+
ExpoConfig = Struct.new(:build_workflow, keyword_init: true) do
|
|
9
|
+
def self.from_hash(h)
|
|
10
|
+
return nil unless h
|
|
11
|
+
|
|
12
|
+
new(build_workflow: h["build_workflow"] || "build.yml")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{ "build_workflow" => build_workflow }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
CapacitorConfig = Struct.new(:build_workflow, keyword_init: true) do
|
|
21
|
+
def self.from_hash(h)
|
|
22
|
+
return nil unless h
|
|
23
|
+
|
|
24
|
+
new(build_workflow: h["build_workflow"] || "capacitor-build.yml")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{ "build_workflow" => build_workflow }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Config
|
|
33
|
+
attr_reader :version_files, :expo, :capacitor
|
|
34
|
+
|
|
35
|
+
def self.load(path = CONFIG_FILE)
|
|
36
|
+
raise "#{CONFIG_FILE} not found. Run `captive-release init` first." unless File.exist?(path)
|
|
37
|
+
|
|
38
|
+
data = JSON.parse(File.read(path))
|
|
39
|
+
new(
|
|
40
|
+
data["version_files"].map { |f| VersionFile.new(f["path"], f["type"]) },
|
|
41
|
+
ExpoConfig.from_hash(data["expo"]),
|
|
42
|
+
CapacitorConfig.from_hash(data["capacitor"])
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.write(version_files, path = CONFIG_FILE, expo: nil, capacitor: nil)
|
|
47
|
+
data = { "version_files" => version_files.map(&:to_h) }
|
|
48
|
+
data["expo"] = expo.to_h if expo
|
|
49
|
+
data["capacitor"] = capacitor.to_h if capacitor
|
|
50
|
+
File.write(path, "#{JSON.pretty_generate(data)}\n")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(version_files, expo = nil, capacitor = nil)
|
|
54
|
+
raise ArgumentError, "version_files cannot be empty" if version_files.empty?
|
|
55
|
+
|
|
56
|
+
@version_files = version_files
|
|
57
|
+
@expo = expo
|
|
58
|
+
@capacitor = capacitor
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def expo?
|
|
62
|
+
!expo.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def capacitor?
|
|
66
|
+
!capacitor.nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def current_version
|
|
70
|
+
version_files.first.read_version
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
class GitCommands
|
|
5
|
+
def self.dirty?
|
|
6
|
+
lines = `git diff --name-only && git diff --cached --name-only`.lines.map(&:strip).reject(&:empty?)
|
|
7
|
+
!lines.empty?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.last_tag
|
|
11
|
+
tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
|
|
12
|
+
tag.empty? ? nil : tag
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.commits_since(tag)
|
|
16
|
+
range = tag ? "#{tag}..HEAD" : "HEAD"
|
|
17
|
+
`git log #{range} --pretty=format:"- %s (%h)" --no-merges`.strip
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.remote_url
|
|
21
|
+
`git remote get-url origin 2>/dev/null`.strip
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.add_and_commit(version, paths)
|
|
25
|
+
run! "git add #{paths.map { |p| shellescape(p) }.join(" ")}"
|
|
26
|
+
run! %(git commit -m "🔖 Set version v#{version}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.tag(version)
|
|
30
|
+
run! "git tag v#{version}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.push(version)
|
|
34
|
+
run! "git push origin HEAD"
|
|
35
|
+
run! "git push origin v#{version}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.run!(cmd)
|
|
39
|
+
system(cmd) || raise("Git command failed: #{cmd}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.shellescape(str)
|
|
43
|
+
str.gsub(/[^a-zA-Z0-9._\-\/]/) { |c| "\\#{c}" }
|
|
44
|
+
end
|
|
45
|
+
private_class_method :shellescape
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "github_http"
|
|
6
|
+
|
|
7
|
+
module CaptiveRelease
|
|
8
|
+
class GithubDispatch
|
|
9
|
+
GITHUB_API = "https://api.github.com"
|
|
10
|
+
|
|
11
|
+
include GithubHttp
|
|
12
|
+
|
|
13
|
+
def self.dispatch(workflow_file:, ref:, inputs:, remote_url:, token:)
|
|
14
|
+
new(workflow_file: workflow_file, ref: ref, inputs: inputs, remote_url: remote_url, token: token).dispatch
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(workflow_file:, ref:, inputs:, remote_url:, token:)
|
|
18
|
+
@workflow_file = workflow_file
|
|
19
|
+
@ref = ref
|
|
20
|
+
@inputs = inputs
|
|
21
|
+
@remote_url = remote_url
|
|
22
|
+
@token = token
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dispatch
|
|
26
|
+
owner, repo = parse_remote(@remote_url)
|
|
27
|
+
uri = URI("#{GITHUB_API}/repos/#{owner}/#{repo}/actions/workflows/#{@workflow_file}/dispatches")
|
|
28
|
+
|
|
29
|
+
payload = JSON.generate({ "ref" => @ref, "inputs" => @inputs })
|
|
30
|
+
response = github_post(uri, @token, payload)
|
|
31
|
+
|
|
32
|
+
unless response.code == "204"
|
|
33
|
+
data = JSON.parse(response.body) rescue {}
|
|
34
|
+
raise "GitHub API error (#{response.code}): #{data["message"] || response.body}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
"https://github.com/#{owner}/#{repo}/actions"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module CaptiveRelease
|
|
7
|
+
module GithubHttp
|
|
8
|
+
MAX_REDIRECTS = 3
|
|
9
|
+
|
|
10
|
+
def github_post(uri, token, body)
|
|
11
|
+
MAX_REDIRECTS.times do
|
|
12
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
13
|
+
http.use_ssl = true
|
|
14
|
+
|
|
15
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
16
|
+
request["Authorization"] = "token #{token}"
|
|
17
|
+
request["Content-Type"] = "application/json"
|
|
18
|
+
request["Accept"] = "application/vnd.github+json"
|
|
19
|
+
request["X-GitHub-Api-Version"] = "2022-11-28"
|
|
20
|
+
request.body = body
|
|
21
|
+
|
|
22
|
+
response = http.request(request)
|
|
23
|
+
|
|
24
|
+
return response unless %w[301 302 307 308].include?(response.code)
|
|
25
|
+
|
|
26
|
+
location = response["location"] || response["Location"]
|
|
27
|
+
raise "GitHub redirect without Location header" unless location
|
|
28
|
+
|
|
29
|
+
uri = URI(location)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
raise "Too many redirects from GitHub API"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse_remote(url)
|
|
36
|
+
match = url.match(%r{github\.com[:/]([^/]+)/(.+?)(?:\.git)?\z})
|
|
37
|
+
raise "Cannot parse GitHub repo from remote URL: #{url}" unless match
|
|
38
|
+
|
|
39
|
+
[match[1], match[2]]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "github_http"
|
|
6
|
+
|
|
7
|
+
module CaptiveRelease
|
|
8
|
+
class GithubRelease
|
|
9
|
+
GITHUB_API = "https://api.github.com"
|
|
10
|
+
|
|
11
|
+
include GithubHttp
|
|
12
|
+
|
|
13
|
+
def self.create(version:, body:, remote_url:, token:)
|
|
14
|
+
new(version: version, body: body, remote_url: remote_url, token: token).create
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(version:, body:, remote_url:, token:)
|
|
18
|
+
@version = version
|
|
19
|
+
@body = body
|
|
20
|
+
@remote_url = remote_url
|
|
21
|
+
@token = token
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
owner, repo = parse_remote(@remote_url)
|
|
26
|
+
uri = URI("#{GITHUB_API}/repos/#{owner}/#{repo}/releases")
|
|
27
|
+
|
|
28
|
+
payload = JSON.generate({
|
|
29
|
+
"tag_name" => "v#{@version}",
|
|
30
|
+
"name" => "v#{@version}",
|
|
31
|
+
"body" => @body.empty? ? "_No commits since last release._" : @body,
|
|
32
|
+
"draft" => false,
|
|
33
|
+
"prerelease" => false
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
response = github_post(uri, @token, payload)
|
|
37
|
+
data = JSON.parse(response.body)
|
|
38
|
+
|
|
39
|
+
raise "GitHub API error (#{response.code}): #{data["message"]}" unless response.code == "201"
|
|
40
|
+
|
|
41
|
+
data["html_url"]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
class VersionBumper
|
|
5
|
+
SEMVER_RE = /\A\d+\.\d+\.\d+\z/
|
|
6
|
+
|
|
7
|
+
def self.bump(current, type)
|
|
8
|
+
new(current).bump(type)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(current)
|
|
12
|
+
raise ArgumentError, "Invalid semver: #{current}" unless current.match?(SEMVER_RE)
|
|
13
|
+
|
|
14
|
+
@major, @minor, @patch = current.split(".").map(&:to_i)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def bump(type)
|
|
18
|
+
case type.to_s
|
|
19
|
+
when "major" then "#{@major + 1}.0.0"
|
|
20
|
+
when "minor" then "#{@major}.#{@minor + 1}.0"
|
|
21
|
+
when "patch" then "#{@major}.#{@minor}.#{@patch + 1}"
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "Invalid version: #{type}" unless type.to_s.match?(SEMVER_RE)
|
|
24
|
+
|
|
25
|
+
type.to_s
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module CaptiveRelease
|
|
6
|
+
class VersionDetector
|
|
7
|
+
EXCLUDED_DIRS = %w[node_modules .git vendor tmp coverage dist .next .nuxt Pods].freeze
|
|
8
|
+
|
|
9
|
+
def self.scan(root = ".")
|
|
10
|
+
new(root).scan
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(root)
|
|
14
|
+
@root = Pathname.new(root).expand_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def scan
|
|
18
|
+
find_files("package.json", "package_json") +
|
|
19
|
+
find_files("app.config.js", "app_config_js") +
|
|
20
|
+
find_ruby_version_files +
|
|
21
|
+
find_files("VERSION", "plain") +
|
|
22
|
+
find_android_gradle_files +
|
|
23
|
+
find_ios_plist_files
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def expo?
|
|
27
|
+
find_files("app.config.js", "app_config_js").any? ||
|
|
28
|
+
find_files("app.json", "plain").any? { |f| File.read(File.join(@root, f.path)).include?('"expo"') }
|
|
29
|
+
rescue
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def capacitor?
|
|
34
|
+
%w[capacitor.config.ts capacitor.config.json capacitor.config.js].any? do |f|
|
|
35
|
+
File.exist?(@root.join(f))
|
|
36
|
+
end
|
|
37
|
+
rescue
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def find_android_gradle_files
|
|
44
|
+
path = @root.join("android", "app", "build.gradle")
|
|
45
|
+
return [] unless File.exist?(path) && File.read(path).match?(/versionName\s+['"]/)
|
|
46
|
+
|
|
47
|
+
[VersionFile.new("android/app/build.gradle", "android_gradle")]
|
|
48
|
+
rescue
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def find_ios_plist_files
|
|
53
|
+
Dir.glob(@root.join("ios", "**", "Info.plist")).sort.reject { |p| excluded?(p) }.select do |path|
|
|
54
|
+
File.read(path).include?("CFBundleShortVersionString")
|
|
55
|
+
end.map do |path|
|
|
56
|
+
relative = Pathname.new(path).relative_path_from(@root).to_s
|
|
57
|
+
VersionFile.new(relative, "ios_plist")
|
|
58
|
+
end
|
|
59
|
+
rescue
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find_ruby_version_files
|
|
64
|
+
Dir.glob(@root.join("lib", "**", "version.rb")).sort.reject { |p| excluded?(p) }.select do |path|
|
|
65
|
+
File.read(path).match?(/VERSION\s*=\s*["'].+?["']/)
|
|
66
|
+
end.map do |path|
|
|
67
|
+
relative = Pathname.new(path).relative_path_from(@root).to_s
|
|
68
|
+
VersionFile.new(relative, "ruby_version")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def find_files(filename, type)
|
|
73
|
+
Dir.glob(@root.join("**", filename)).sort.reject { |p| excluded?(p) }.map do |path|
|
|
74
|
+
relative = Pathname.new(path).relative_path_from(@root).to_s
|
|
75
|
+
VersionFile.new(relative, type)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def excluded?(path)
|
|
80
|
+
EXCLUDED_DIRS.any? { |dir| path.include?("/#{dir}/") || path.include?("/#{dir}\\") }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module CaptiveRelease
|
|
6
|
+
class VersionFile
|
|
7
|
+
TYPES = %w[package_json plain app_config_js ruby_version android_gradle ios_plist].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :path, :type
|
|
10
|
+
|
|
11
|
+
def initialize(path, type)
|
|
12
|
+
raise ArgumentError, "Unknown type #{type}. Must be one of: #{TYPES.join(", ")}" unless TYPES.include?(type)
|
|
13
|
+
|
|
14
|
+
@path = path
|
|
15
|
+
@type = type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read_version
|
|
19
|
+
raise "File not found: #{path}" unless File.exist?(path)
|
|
20
|
+
|
|
21
|
+
case type
|
|
22
|
+
when "package_json" then JSON.parse(File.read(path))["version"]
|
|
23
|
+
when "plain" then File.read(path).strip
|
|
24
|
+
when "app_config_js" then read_app_config_js
|
|
25
|
+
when "ruby_version" then read_ruby_version
|
|
26
|
+
when "android_gradle" then read_android_gradle
|
|
27
|
+
when "ios_plist" then read_ios_plist
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_version(new_version)
|
|
32
|
+
raise "File not found: #{path}" unless File.exist?(path)
|
|
33
|
+
|
|
34
|
+
case type
|
|
35
|
+
when "package_json" then write_package_json(new_version)
|
|
36
|
+
when "plain" then File.write(path, "#{new_version}\n")
|
|
37
|
+
when "app_config_js" then write_app_config_js(new_version)
|
|
38
|
+
when "ruby_version" then write_ruby_version(new_version)
|
|
39
|
+
when "android_gradle" then write_android_gradle(new_version)
|
|
40
|
+
when "ios_plist" then write_ios_plist(new_version)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_h
|
|
45
|
+
{ "path" => path, "type" => type }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def write_package_json(new_version)
|
|
51
|
+
content = File.read(path)
|
|
52
|
+
old_version = JSON.parse(content)["version"]
|
|
53
|
+
File.write(path, content.gsub(/"version": "#{Regexp.escape(old_version)}"/, %("version": "#{new_version}")))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_ruby_version
|
|
57
|
+
File.read(path).match(/VERSION\s*=\s*["'](.+?)["']/)[1]
|
|
58
|
+
rescue
|
|
59
|
+
raise "Cannot read version from #{path} — expected pattern: VERSION = \"x.y.z\""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def write_ruby_version(new_version)
|
|
63
|
+
content = File.read(path)
|
|
64
|
+
old_version = read_ruby_version
|
|
65
|
+
File.write(path, content.gsub(/VERSION\s*=\s*["']#{Regexp.escape(old_version)}["']/) do |match|
|
|
66
|
+
match.include?('"') ? %(VERSION = "#{new_version}") : %(VERSION = '#{new_version}')
|
|
67
|
+
end)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_app_config_js
|
|
71
|
+
File.read(path).match(/version:\s*['"](.+?)['"]/)[1]
|
|
72
|
+
rescue
|
|
73
|
+
raise "Cannot read version from #{path} — expected pattern: version: 'x.y.z'"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def write_app_config_js(new_version)
|
|
77
|
+
content = File.read(path)
|
|
78
|
+
old_version = read_app_config_js
|
|
79
|
+
File.write(path, content.gsub(/version:\s*['"]#{Regexp.escape(old_version)}['"]/, "version: '#{new_version}'"))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_android_gradle
|
|
83
|
+
File.read(path).match(/versionName\s+['"](.+?)['"]/)&.[](1) ||
|
|
84
|
+
raise("Cannot read version from #{path} — expected pattern: versionName 'x.y.z'")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_android_gradle(new_version)
|
|
88
|
+
content = File.read(path)
|
|
89
|
+
old_version = read_android_gradle
|
|
90
|
+
File.write(path, content.gsub(/versionName\s+['"]#{Regexp.escape(old_version)}['"]/, "versionName '#{new_version}'"))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read_ios_plist
|
|
94
|
+
File.read(path).match(/<key>CFBundleShortVersionString<\/key>\s*<string>(.+?)<\/string>/)&.[](1) ||
|
|
95
|
+
raise("Cannot read version from #{path} — expected CFBundleShortVersionString key")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def write_ios_plist(new_version)
|
|
99
|
+
content = File.read(path)
|
|
100
|
+
old_version = read_ios_plist
|
|
101
|
+
File.write(path, content.gsub(
|
|
102
|
+
/(<key>CFBundleShortVersionString<\/key>\s*<string>)#{Regexp.escape(old_version)}(<\/string>)/,
|
|
103
|
+
"\\1#{new_version}\\2"
|
|
104
|
+
))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "captive_release/version"
|
|
4
|
+
require_relative "captive_release/version_bumper"
|
|
5
|
+
require_relative "captive_release/version_file"
|
|
6
|
+
require_relative "captive_release/config"
|
|
7
|
+
require_relative "captive_release/version_detector"
|
|
8
|
+
require_relative "captive_release/git_commands"
|
|
9
|
+
require_relative "captive_release/github_release"
|
|
10
|
+
require_relative "captive_release/github_dispatch"
|
|
11
|
+
require_relative "captive_release/cli"
|
|
12
|
+
|
|
13
|
+
module CaptiveRelease
|
|
14
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: captive-release
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Captive Studio
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Gère les bumps de version (patch/minor/major/custom), met à jour package.json
|
|
13
|
+
et fichiers VERSION, crée le commit, le tag et la release GitHub.
|
|
14
|
+
email:
|
|
15
|
+
- dev@captive.fr
|
|
16
|
+
executables:
|
|
17
|
+
- captive-release
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- bin/captive-release
|
|
22
|
+
- lib/captive_release.rb
|
|
23
|
+
- lib/captive_release/cli.rb
|
|
24
|
+
- lib/captive_release/config.rb
|
|
25
|
+
- lib/captive_release/git_commands.rb
|
|
26
|
+
- lib/captive_release/github_dispatch.rb
|
|
27
|
+
- lib/captive_release/github_http.rb
|
|
28
|
+
- lib/captive_release/github_release.rb
|
|
29
|
+
- lib/captive_release/version.rb
|
|
30
|
+
- lib/captive_release/version_bumper.rb
|
|
31
|
+
- lib/captive_release/version_detector.rb
|
|
32
|
+
- lib/captive_release/version_file.rb
|
|
33
|
+
homepage: https://github.com/captive-studio/captive-release
|
|
34
|
+
licenses:
|
|
35
|
+
- MIT
|
|
36
|
+
metadata: {}
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '3.2'
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 4.0.16
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: CLI de release semver pour projets Captive
|
|
54
|
+
test_files: []
|