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 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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "captive_release"
7
+
8
+ CaptiveRelease::CLI.run(ARGV)
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ VERSION = "0.3.0"
5
+ 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: []