captive-release 0.3.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/captive_release/build_command.rb +52 -0
- data/lib/captive_release/build_config.rb +20 -0
- data/lib/captive_release/build_config_prompt.rb +31 -0
- data/lib/captive_release/build_dispatch_run.rb +35 -0
- data/lib/captive_release/cli.rb +27 -382
- data/lib/captive_release/config.rb +2 -44
- data/lib/captive_release/config_file.rb +30 -0
- data/lib/captive_release/config_loader.rb +34 -0
- data/lib/captive_release/config_writer.rb +45 -0
- data/lib/captive_release/framework_detector.rb +30 -0
- data/lib/captive_release/git_commands.rb +18 -27
- data/lib/captive_release/git_release.rb +24 -0
- data/lib/captive_release/github_api.rb +45 -0
- data/lib/captive_release/github_dispatch.rb +12 -27
- data/lib/captive_release/github_http.rb +34 -25
- data/lib/captive_release/github_release.rb +16 -32
- data/lib/captive_release/github_repo.rb +29 -0
- data/lib/captive_release/github_token.rb +18 -0
- data/lib/captive_release/help.rb +50 -0
- data/lib/captive_release/init_command.rb +44 -0
- data/lib/captive_release/menu.rb +33 -0
- data/lib/captive_release/options.rb +27 -0
- data/lib/captive_release/release_command.rb +42 -0
- data/lib/captive_release/release_preconditions.rb +24 -0
- data/lib/captive_release/release_publication.rb +30 -0
- data/lib/captive_release/repo_files.rb +35 -0
- data/lib/captive_release/shell.rb +20 -0
- data/lib/captive_release/terminal.rb +37 -0
- data/lib/captive_release/version.rb +1 -1
- data/lib/captive_release/version_choice.rb +56 -0
- data/lib/captive_release/version_detector.rb +26 -57
- data/lib/captive_release/version_file.rb +14 -79
- data/lib/captive_release/version_file_bootstrap.rb +25 -0
- data/lib/captive_release/version_file_selection.rb +53 -0
- data/lib/captive_release/version_files_argument.rb +21 -0
- data/lib/captive_release/version_files_writing.rb +22 -0
- data/lib/captive_release/version_format.rb +86 -0
- data/lib/captive_release.rb +29 -0
- metadata +29 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# Trouver la configuration du dépôt, ou la déduire par détection quand elle n'a jamais été
|
|
5
|
+
# écrite : un dépôt qui n'a qu'un fichier VERSION doit pouvoir releaser sans init préalable.
|
|
6
|
+
#
|
|
7
|
+
# Cette unité change quand les sources de configuration changent, les commandes quand leur
|
|
8
|
+
# geste change.
|
|
9
|
+
class ConfigLoader
|
|
10
|
+
def initialize(terminal:)
|
|
11
|
+
@terminal = terminal
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
return ConfigFile.load if File.exist?(CONFIG_FILE)
|
|
16
|
+
|
|
17
|
+
fichiers = VersionDetector.scan
|
|
18
|
+
refuser if fichiers.empty?
|
|
19
|
+
|
|
20
|
+
Config.new(fichiers)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def refuser
|
|
26
|
+
@terminal.say @terminal.error(
|
|
27
|
+
"Aucun fichier de version trouvé (package.json, lib/**/version.rb, VERSION).",
|
|
28
|
+
)
|
|
29
|
+
@terminal.say "Créez un fichier #{@terminal.bold("VERSION")} " \
|
|
30
|
+
"ou lancez #{@terminal.bold("captive-release init")}.\n"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# Compose captive-release.json et l'écrit : les fichiers de version retenus, et les
|
|
5
|
+
# workflows de build si le dépôt en réclame.
|
|
6
|
+
#
|
|
7
|
+
# Cette unité change quand la forme de la configuration change, InitCommand quand la façon
|
|
8
|
+
# de réunir les fichiers change.
|
|
9
|
+
class ConfigWriter
|
|
10
|
+
def initialize(terminal:, frameworks: nil)
|
|
11
|
+
@terminal = terminal
|
|
12
|
+
@frameworks = frameworks || FrameworkDetector.new(RepoFiles.new("."))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(fichiers)
|
|
16
|
+
refuser_le_vide if fichiers.empty?
|
|
17
|
+
|
|
18
|
+
expo = demander("Expo", EXPO_DEFAULT_WORKFLOW) if @frameworks.expo?
|
|
19
|
+
capacitor = demander("Capacitor", CAPACITOR_DEFAULT_WORKFLOW) if @frameworks.capacitor?
|
|
20
|
+
|
|
21
|
+
ConfigFile.write(fichiers, expo: expo, capacitor: capacitor)
|
|
22
|
+
annoncer(fichiers, expo, capacitor)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def refuser_le_vide
|
|
28
|
+
@terminal.say @terminal.error("No files selected.")
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def annoncer(fichiers, expo, capacitor)
|
|
33
|
+
@terminal.say "\n#{@terminal.ok("✅ captive-release.json written with " \
|
|
34
|
+
"#{fichiers.size} version file(s).")}"
|
|
35
|
+
@terminal.say " #{@terminal.dim("Expo build workflow: #{expo.build_workflow}")}" if expo
|
|
36
|
+
return unless capacitor
|
|
37
|
+
|
|
38
|
+
@terminal.say " #{@terminal.dim("Capacitor build workflow: #{capacitor.build_workflow}")}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def demander(framework, workflow_par_defaut)
|
|
42
|
+
BuildConfigPrompt.new(terminal: @terminal).call(framework, workflow_par_defaut)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# Quel framework mobile ce dépôt utilise, donc quel workflow de build proposer.
|
|
5
|
+
#
|
|
6
|
+
# Cette unité change quand un framework s'ajoute, VersionDetector quand un format de
|
|
7
|
+
# fichier de version s'ajoute : deux raisons de changer différentes.
|
|
8
|
+
class FrameworkDetector
|
|
9
|
+
CAPACITOR_CONFIGS = %w[capacitor.config.ts capacitor.config.json capacitor.config.js].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(files)
|
|
12
|
+
@files = files
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Les rescue existent pour qu'une arborescence hostile ne fasse pas échouer une release :
|
|
16
|
+
# la détection est une commodité, jamais une raison de s'arrêter.
|
|
17
|
+
def expo?
|
|
18
|
+
@files.glob("**", "app.config.js").any? ||
|
|
19
|
+
@files.glob("**", "app.json").any? { |chemin| File.read(chemin).include?('"expo"') }
|
|
20
|
+
rescue StandardError
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def capacitor?
|
|
25
|
+
CAPACITOR_CONFIGS.any? { |nom| File.exist?(@files.path(nom)) }
|
|
26
|
+
rescue StandardError
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,47 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CaptiveRelease
|
|
4
|
+
# Les commandes git d'une release. La façon de les lancer vit dans Shell.
|
|
4
5
|
class GitCommands
|
|
5
6
|
def self.dirty?
|
|
6
|
-
|
|
7
|
-
!lines.empty?
|
|
7
|
+
sortie = Shell.capture("git diff --name-only && git diff --cached --name-only")
|
|
8
|
+
!sortie.lines.map(&:strip).reject(&:empty?).empty?
|
|
8
9
|
end
|
|
9
10
|
|
|
10
|
-
def self.
|
|
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
|
|
11
|
+
def self.remote_url = Shell.capture("git remote get-url origin 2>/dev/null")
|
|
19
12
|
|
|
20
|
-
def self.
|
|
21
|
-
|
|
13
|
+
def self.current_branch
|
|
14
|
+
branche = Shell.capture("git rev-parse --abbrev-ref HEAD 2>/dev/null")
|
|
15
|
+
branche.empty? ? "main" : branche
|
|
22
16
|
end
|
|
23
17
|
|
|
18
|
+
# --only borne le commit aux fichiers de version : sans lui, ce qui traînait dans l'index
|
|
19
|
+
# part dans la release, taggé et poussé, sans que rien ne le signale.
|
|
24
20
|
def self.add_and_commit(version, paths)
|
|
25
|
-
|
|
26
|
-
run!
|
|
21
|
+
echappes = paths.map { |chemin| Shell.escape(chemin) }.join(" ")
|
|
22
|
+
Shell.run! "git add #{echappes}"
|
|
23
|
+
Shell.run! %(git commit --only #{echappes} -m "🔖 Set version v#{version}")
|
|
27
24
|
end
|
|
28
25
|
|
|
29
26
|
def self.tag(version)
|
|
30
|
-
run! "git tag v#{version}"
|
|
27
|
+
Shell.run! "git tag v#{version}"
|
|
31
28
|
end
|
|
32
29
|
|
|
30
|
+
# Le tag part avant HEAD : au moment où le webhook du push sur main arrive, le tag existe
|
|
31
|
+
# déjà. Dans l'autre sens, demander « ce sha porte-t-il un tag v* ? » répond non presque
|
|
32
|
+
# toujours, et la même release se construit deux fois.
|
|
33
33
|
def self.push(version)
|
|
34
|
-
run! "git push origin
|
|
35
|
-
run! "git push origin
|
|
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}" }
|
|
34
|
+
Shell.run! "git push origin v#{version}"
|
|
35
|
+
Shell.run! "git push origin HEAD"
|
|
44
36
|
end
|
|
45
|
-
private_class_method :shellescape
|
|
46
37
|
end
|
|
47
38
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# La séquence git d'une release : commiter les fichiers de version, taguer, pousser.
|
|
5
|
+
#
|
|
6
|
+
# Cette unité change quand la séquence change, ReleaseCommand quand ce qui l'entoure
|
|
7
|
+
# change. Chaque étape est annoncée au fur et à mesure : une release qui s'arrête doit
|
|
8
|
+
# laisser voir jusqu'où elle est allée.
|
|
9
|
+
class GitRelease
|
|
10
|
+
def initialize(terminal:)
|
|
11
|
+
@terminal = terminal
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(version, paths)
|
|
15
|
+
@terminal.say "\n#{@terminal.bold("Running git commands...")}"
|
|
16
|
+
GitCommands.add_and_commit(version, paths)
|
|
17
|
+
@terminal.done %(git commit "🔖 Set version v#{version}")
|
|
18
|
+
GitCommands.tag(version)
|
|
19
|
+
@terminal.done "git tag v#{version}"
|
|
20
|
+
GitCommands.push(version)
|
|
21
|
+
@terminal.done "git push + tag"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "github_http"
|
|
6
|
+
require_relative "github_repo"
|
|
7
|
+
|
|
8
|
+
module CaptiveRelease
|
|
9
|
+
# L'API GitHub d'un dépôt : à quelle adresse poster, et ce qui compte comme un échec. Les
|
|
10
|
+
# commandes lui parlent par délégation plutôt que d'en hériter le transport, ce qui leur
|
|
11
|
+
# laisse la seule composition de leur payload.
|
|
12
|
+
#
|
|
13
|
+
# Cette unité change quand l'adressage ou la politique d'erreur changent, GithubHttp quand
|
|
14
|
+
# le transport change.
|
|
15
|
+
class GithubApi
|
|
16
|
+
HOTE = "https://api.github.com"
|
|
17
|
+
|
|
18
|
+
attr_reader :depot
|
|
19
|
+
|
|
20
|
+
def initialize(remote_url:, token:, http: nil)
|
|
21
|
+
@depot = GithubRepo.from_remote(remote_url)
|
|
22
|
+
@http = http || GithubHttp.new(token: token)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def post(chemin, corps, attendu:)
|
|
26
|
+
reponse = @http.post(URI("#{HOTE}/#{depot.path(chemin)}"), JSON.generate(corps))
|
|
27
|
+
refuser(reponse) unless reponse.code == attendu
|
|
28
|
+
|
|
29
|
+
reponse.body.to_s.empty? ? nil : JSON.parse(reponse.body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Le corps d'une erreur GitHub est du JSON qui nomme la cause, sauf quand il n'en est pas :
|
|
35
|
+
# un 500 rend parfois du texte brut, qu'il vaut mieux montrer que d'avaler.
|
|
36
|
+
def refuser(reponse)
|
|
37
|
+
data = begin
|
|
38
|
+
JSON.parse(reponse.body)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
{}
|
|
41
|
+
end
|
|
42
|
+
raise "GitHub API error (#{reponse.code}): #{data["message"] || reponse.body}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -1,40 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "uri"
|
|
5
|
-
require_relative "github_http"
|
|
3
|
+
require_relative "github_api"
|
|
6
4
|
|
|
7
5
|
module CaptiveRelease
|
|
6
|
+
# Déclenche un workflow GitHub Actions. Elle compose ce qu'elle demande ; l'API, son
|
|
7
|
+
# authentification et ses échecs vivent dans GithubApi.
|
|
8
8
|
class GithubDispatch
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
9
|
+
def self.dispatch(workflow_file:, ref:, inputs:, remote_url:, token:, api: nil)
|
|
10
|
+
api ||= GithubApi.new(remote_url: remote_url, token: token)
|
|
11
|
+
new(api).dispatch(workflow_file: workflow_file, ref: ref, inputs: inputs)
|
|
15
12
|
end
|
|
16
13
|
|
|
17
|
-
def initialize(
|
|
18
|
-
@
|
|
19
|
-
@ref = ref
|
|
20
|
-
@inputs = inputs
|
|
21
|
-
@remote_url = remote_url
|
|
22
|
-
@token = token
|
|
14
|
+
def initialize(api)
|
|
15
|
+
@api = api
|
|
23
16
|
end
|
|
24
17
|
|
|
25
|
-
def dispatch
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
18
|
+
def dispatch(workflow_file:, ref:, inputs:)
|
|
19
|
+
@api.post("actions/workflows/#{workflow_file}/dispatches",
|
|
20
|
+
{ "ref" => ref, "inputs" => inputs }, attendu: "204")
|
|
36
21
|
|
|
37
|
-
|
|
22
|
+
@api.depot.actions_url
|
|
38
23
|
end
|
|
39
24
|
end
|
|
40
25
|
end
|
|
@@ -4,39 +4,48 @@ require "net/http"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
|
|
6
6
|
module CaptiveRelease
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
response = http.request(request)
|
|
23
|
-
|
|
24
|
-
return response unless %w[301 302 307 308].include?(response.code)
|
|
7
|
+
# Le transport : poster sur l'API GitHub, authentifié, en suivant les redirections qu'elle
|
|
8
|
+
# sert quand un dépôt a été renommé. Elle ne sait rien de ce qui est posté ni de ce qui
|
|
9
|
+
# compte comme un échec, qui appartiennent à GithubApi.
|
|
10
|
+
class GithubHttp
|
|
11
|
+
MAX_REDIRECTIONS = 3
|
|
12
|
+
REDIRECTIONS = %w[301 302 307 308].freeze
|
|
13
|
+
EN_TETES = {
|
|
14
|
+
"Content-Type" => "application/json",
|
|
15
|
+
"Accept" => "application/vnd.github+json",
|
|
16
|
+
"X-GitHub-Api-Version" => "2022-11-28",
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(token:)
|
|
20
|
+
@token = token
|
|
21
|
+
end
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
def post(uri, corps)
|
|
24
|
+
MAX_REDIRECTIONS.times do
|
|
25
|
+
reponse = envoyer(uri, corps)
|
|
26
|
+
return reponse unless REDIRECTIONS.include?(reponse.code)
|
|
28
27
|
|
|
29
|
-
uri = URI(
|
|
28
|
+
uri = URI(ailleurs!(reponse))
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
raise "Too many redirects from GitHub API"
|
|
33
32
|
end
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def envoyer(uri, corps)
|
|
37
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
38
|
+
http.use_ssl = true
|
|
39
|
+
requete = Net::HTTP::Post.new(uri.path)
|
|
40
|
+
EN_TETES.each { |nom, valeur| requete[nom] = valeur }
|
|
41
|
+
requete["Authorization"] = "token #{@token}"
|
|
42
|
+
requete.body = corps
|
|
43
|
+
http.request(requete)
|
|
44
|
+
end
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
def ailleurs!(reponse)
|
|
47
|
+
reponse["location"] || reponse["Location"] ||
|
|
48
|
+
raise("GitHub redirect without Location header")
|
|
40
49
|
end
|
|
41
50
|
end
|
|
42
51
|
end
|
|
@@ -1,44 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "uri"
|
|
5
|
-
require_relative "github_http"
|
|
3
|
+
require_relative "github_api"
|
|
6
4
|
|
|
7
5
|
module CaptiveRelease
|
|
6
|
+
# Crée la release GitHub d'une version. Elle compose ce qu'elle demande ; l'API, son
|
|
7
|
+
# authentification et ses échecs vivent dans GithubApi.
|
|
8
8
|
class GithubRelease
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def self.create(version:, body:, remote_url:, token:)
|
|
14
|
-
new(version: version, body: body, remote_url: remote_url, token: token).create
|
|
9
|
+
def self.create(version:, remote_url:, token:, api: nil)
|
|
10
|
+
api ||= GithubApi.new(remote_url: remote_url, token: token)
|
|
11
|
+
new(api).create(version)
|
|
15
12
|
end
|
|
16
13
|
|
|
17
|
-
def initialize(
|
|
18
|
-
@
|
|
19
|
-
@body = body
|
|
20
|
-
@remote_url = remote_url
|
|
21
|
-
@token = token
|
|
14
|
+
def initialize(api)
|
|
15
|
+
@api = api
|
|
22
16
|
end
|
|
23
17
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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"]
|
|
18
|
+
# GitHub compose le corps : commits catégorisés, contributeurs, lien Full Changelog. Le
|
|
19
|
+
# composer ici obligeait à lister les commits, et sans tag précédent cette liste était
|
|
20
|
+
# l'historique entier du dépôt.
|
|
21
|
+
def create(version)
|
|
22
|
+
@api.post("releases",
|
|
23
|
+
{ "tag_name" => "v#{version}", "name" => "v#{version}",
|
|
24
|
+
"generate_release_notes" => true, "draft" => false, "prerelease" => false, },
|
|
25
|
+
attendu: "201")["html_url"]
|
|
42
26
|
end
|
|
43
27
|
end
|
|
44
28
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# Quel dépôt GitHub un remote git désigne. Les deux formes de remote, SSH et HTTPS, mènent
|
|
5
|
+
# au même couple propriétaire et dépôt.
|
|
6
|
+
#
|
|
7
|
+
# Cette unité change quand les formes de remote changent, GithubApi quand l'API change.
|
|
8
|
+
class GithubRepo
|
|
9
|
+
REMOTE = %r{github\.com[:/]([^/]+)/(.+?)(?:\.git)?\z}
|
|
10
|
+
|
|
11
|
+
attr_reader :owner, :repo
|
|
12
|
+
|
|
13
|
+
def self.from_remote(url)
|
|
14
|
+
match = url.match(REMOTE)
|
|
15
|
+
raise "Cannot parse GitHub repo from remote URL: #{url}" unless match
|
|
16
|
+
|
|
17
|
+
new(match[1], match[2])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(owner, repo)
|
|
21
|
+
@owner = owner
|
|
22
|
+
@repo = repo
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def path(suite) = "repos/#{owner}/#{repo}/#{suite}"
|
|
26
|
+
|
|
27
|
+
def actions_url = "https://github.com/#{owner}/#{repo}/actions"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# D'où vient le jeton GitHub : la variable d'environnement d'abord, la CLI `gh` ensuite.
|
|
5
|
+
#
|
|
6
|
+
# Cette unité change quand les sources d'authentification changent. La lecture reste
|
|
7
|
+
# paresseuse chez les appelants, qui la reçoivent en fournisseur : interroger `gh` coûte un
|
|
8
|
+
# appel système, inutile tant qu'on n'a pas vérifié le reste.
|
|
9
|
+
module GithubToken
|
|
10
|
+
def self.call
|
|
11
|
+
depuis_env = ENV["GITHUB_TOKEN"].to_s
|
|
12
|
+
return depuis_env unless depuis_env.empty?
|
|
13
|
+
|
|
14
|
+
depuis_gh = `gh auth token 2>/dev/null`.strip
|
|
15
|
+
depuis_gh.empty? ? nil : depuis_gh
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# Ce que la CLI documente d'elle-même. Cette unité change quand la documentation de
|
|
5
|
+
# l'interface change, la CLI quand son aiguillage change.
|
|
6
|
+
class Help
|
|
7
|
+
def initialize(terminal:)
|
|
8
|
+
@terminal = terminal
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
@terminal.say <<~HELP
|
|
13
|
+
|
|
14
|
+
#{@terminal.bold("captive-release")} : Semver release tool
|
|
15
|
+
|
|
16
|
+
#{@terminal.bold("Usage:")}
|
|
17
|
+
captive-release init [--files path[:type],...]
|
|
18
|
+
captive-release new [patch|minor|major|x.y.z] [--yes]
|
|
19
|
+
captive-release version
|
|
20
|
+
|
|
21
|
+
#{@terminal.bold("Commands:")}
|
|
22
|
+
init Detect version files and write captive-release.json
|
|
23
|
+
new Bump version, commit, tag, push, create GitHub release
|
|
24
|
+
Refuse de tourner sur un arbre de travail sale.
|
|
25
|
+
Demande toujours confirmation, sauf avec --yes.
|
|
26
|
+
build Dispatch an EAS build workflow (staging/production) via GitHub Actions
|
|
27
|
+
|
|
28
|
+
#{@terminal.bold("Options for init:")}
|
|
29
|
+
--files Non-interactive: comma-separated list of files with optional type
|
|
30
|
+
Type is auto-detected (package_json or plain) when omitted
|
|
31
|
+
Example: --files=package.json,ios/VERSION:plain
|
|
32
|
+
|
|
33
|
+
#{@terminal.bold("Examples:")}
|
|
34
|
+
captive-release init
|
|
35
|
+
captive-release init --files=package.json,apps/mobile/package.json
|
|
36
|
+
captive-release new
|
|
37
|
+
captive-release new patch
|
|
38
|
+
captive-release new 2.0.0
|
|
39
|
+
captive-release new patch --yes
|
|
40
|
+
captive-release build
|
|
41
|
+
captive-release build --profile staging
|
|
42
|
+
captive-release build --profile production
|
|
43
|
+
|
|
44
|
+
#{@terminal.bold("GitHub release:")}
|
|
45
|
+
Set GITHUB_TOKEN env var or authenticate with `gh auth login`
|
|
46
|
+
|
|
47
|
+
HELP
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# Détecte les fichiers de version d'un dépôt et écrit captive-release.json.
|
|
5
|
+
#
|
|
6
|
+
# Cette unité change quand la façon de constituer la configuration change, la CLI quand
|
|
7
|
+
# l'ensemble des commandes ou le parsing des options change.
|
|
8
|
+
class InitCommand
|
|
9
|
+
def initialize(terminal:)
|
|
10
|
+
@terminal = terminal
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(files_arg)
|
|
14
|
+
files_arg ? sans_interaction(files_arg) : avec_interaction
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def sans_interaction(files_arg)
|
|
20
|
+
writer.call(VersionFilesArgument.parse(files_arg))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def avec_interaction
|
|
24
|
+
@terminal.say "\n#{@terminal.bold("captive-release init")}\n"
|
|
25
|
+
@terminal.say "Scanning for version files...\n"
|
|
26
|
+
|
|
27
|
+
trouves = VersionDetector.scan
|
|
28
|
+
return creer_un_version("No version files found (package.json, VERSION).") if trouves.empty?
|
|
29
|
+
|
|
30
|
+
retenus = selection.call(trouves)
|
|
31
|
+
return creer_un_version("Aucun fichier sélectionné.") if retenus.empty?
|
|
32
|
+
|
|
33
|
+
writer.call(retenus)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def creer_un_version(motif)
|
|
37
|
+
writer.call(VersionFileBootstrap.new(terminal: @terminal).call(motif))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def writer = @writer ||= ConfigWriter.new(terminal: @terminal)
|
|
41
|
+
|
|
42
|
+
def selection = @selection ||= VersionFileSelection.new(terminal: @terminal)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# Une liste numérotée, un choix, et le refus de ce qui n'est pas dans la liste.
|
|
5
|
+
#
|
|
6
|
+
# Un rang négatif est refusé comme un rang trop grand : sans cette garde, une saisie non
|
|
7
|
+
# numérique vaut 0, donc l'indice -1, et Ruby rendait silencieusement la dernière entrée.
|
|
8
|
+
class Menu
|
|
9
|
+
def initialize(terminal:)
|
|
10
|
+
@terminal = terminal
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# entries : un Hash {libellé affiché => valeur rendue}, dans l'ordre d'affichage.
|
|
14
|
+
def choose(title, entries)
|
|
15
|
+
@terminal.say "#{title}\n"
|
|
16
|
+
entries.each_key.with_index do |libelle, rang|
|
|
17
|
+
@terminal.say " #{@terminal.dim("#{rang + 1})")} #{libelle}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
rang = @terminal.ask_choice - 1
|
|
21
|
+
refuse if rang.negative? || rang >= entries.size
|
|
22
|
+
|
|
23
|
+
entries.values[rang]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def refuse
|
|
29
|
+
@terminal.say @terminal.error("Invalid choice.")
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CaptiveRelease
|
|
4
|
+
# La ligne de commande et ce qu'on en lit : la commande, les drapeaux, les options à
|
|
5
|
+
# valeur, sous leurs deux formes « --opt valeur » et « --opt=valeur ».
|
|
6
|
+
#
|
|
7
|
+
# Cette unité change quand la syntaxe des options change, la CLI quand l'ensemble des
|
|
8
|
+
# commandes change. Elle consomme ce qu'elle lit, pour qu'un argument positionnel lu
|
|
9
|
+
# ensuite ne récupère pas une option.
|
|
10
|
+
class Options
|
|
11
|
+
def initialize(argv)
|
|
12
|
+
@argv = argv
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def next_argument = @argv.shift
|
|
16
|
+
|
|
17
|
+
def flag?(name) = !@argv.delete(name).nil?
|
|
18
|
+
|
|
19
|
+
def value(name)
|
|
20
|
+
rang = @argv.index { |argument| argument.start_with?("#{name}=") || argument == name }
|
|
21
|
+
return nil if rang.nil?
|
|
22
|
+
|
|
23
|
+
argument = @argv.delete_at(rang)
|
|
24
|
+
argument.include?("=") ? argument.split("=", 2).last : @argv.delete_at(rang)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|