inquirex-tools 0.8.0 → 0.9.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
- data/.env.encrypted +11 -0
- data/.env.example +5 -0
- data/.envrc +1 -0
- data/.relaxed_rubocop.yml +153 -0
- data/.sops.yaml +6 -0
- data/.sopsy.sha +1 -0
- data/.sopsy.yml +11 -0
- data/docs/badges/coverage_badge.svg +21 -0
- data/docs/monorepo-migration.md +163 -0
- data/exe/inquirex +3 -2
- data/exe/inquirexor +1 -0
- data/justfile +158 -0
- data/lib/inquirex/tools/changelogs.rb +21 -3
- data/lib/inquirex/tools/commands/changelogs.rb +5 -3
- data/lib/inquirex/tools/commands/create_release.rb +56 -0
- data/lib/inquirex/tools/commands/publish.rb +50 -0
- data/lib/inquirex/tools/commands/versions_bump.rb +34 -0
- data/lib/inquirex/tools/commands/versions_check.rb +36 -0
- data/lib/inquirex/tools/commands.rb +11 -3
- data/lib/inquirex/tools/publisher.rb +229 -0
- data/lib/inquirex/tools/releaser.rb +163 -0
- data/lib/inquirex/tools/version.rb +1 -1
- data/lib/inquirex/tools/version_bumper.rb +230 -0
- data/lib/inquirex/tools/version_checker.rb +113 -0
- data/lib/inquirex/tools/workspace.rb +30 -27
- data/lib/inquirex/tools.rb +12 -5
- metadata +35 -3
- data/lib/inquirex/tools/commands/versions.rb +0 -41
- data/lib/inquirex/tools/versions.rb +0 -125
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Tools
|
|
5
|
+
module Commands
|
|
6
|
+
# `inquirex create-release` — tags and publishes a GitHub release for
|
|
7
|
+
# every package in the family.
|
|
8
|
+
#
|
|
9
|
+
# The orchestrator runs each package's own `just release` in that
|
|
10
|
+
# package's directory, so how a repository tags and writes its notes
|
|
11
|
+
# stays that repository's business. What this adds is the order, refusing
|
|
12
|
+
# to tag a dirty tree or a feature branch, and one command instead of six.
|
|
13
|
+
#
|
|
14
|
+
# Separate from `publish` on purpose. Publishing is irreversible and
|
|
15
|
+
# authenticated; releasing is neither, and the two fail for entirely
|
|
16
|
+
# different reasons — a release that cannot find `gh` should not leave a
|
|
17
|
+
# half-published family behind it.
|
|
18
|
+
class CreateRelease < Dry::CLI::Command
|
|
19
|
+
desc "Tag and create a GitHub release for every package in the family"
|
|
20
|
+
|
|
21
|
+
option :dry_run,
|
|
22
|
+
type: :boolean,
|
|
23
|
+
default: false,
|
|
24
|
+
desc: "Print what would run; tag nothing"
|
|
25
|
+
|
|
26
|
+
option :force,
|
|
27
|
+
type: :boolean,
|
|
28
|
+
default: false,
|
|
29
|
+
desc: "Release even from a dirty tree or a branch other than main"
|
|
30
|
+
|
|
31
|
+
example [
|
|
32
|
+
" # release every package at its current version",
|
|
33
|
+
"--dry-run # print what would happen, tag nothing",
|
|
34
|
+
"--force # skip the clean-tree and main-branch checks"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Safe to re-run: each package's recipe force-moves its tag and
|
|
38
|
+
# recreates its GitHub release, so a run that stopped halfway can be
|
|
39
|
+
# fixed and reissued rather than unpicked.
|
|
40
|
+
#
|
|
41
|
+
# @param options [Hash] parsed CLI options
|
|
42
|
+
# @return [void]
|
|
43
|
+
def call(**options)
|
|
44
|
+
ok = Inquirex::Tools::Releaser.new.call(
|
|
45
|
+
dry_run: options.fetch(:dry_run, false),
|
|
46
|
+
force: options.fetch(:force, false)
|
|
47
|
+
)
|
|
48
|
+
exit(1) unless ok
|
|
49
|
+
rescue Inquirex::Tools::Error => e
|
|
50
|
+
warn "ERROR: #{e.message}"
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Tools
|
|
5
|
+
module Commands
|
|
6
|
+
# `inquirex publish` — publishes every package in the family.
|
|
7
|
+
#
|
|
8
|
+
# The orchestrator runs each package's own `just publish` in that
|
|
9
|
+
# package's directory, so how a package reaches its registry stays that
|
|
10
|
+
# package's business. What this adds is the order, a fresh 2FA code per
|
|
11
|
+
# gem, and stopping at the first failure.
|
|
12
|
+
class Publish < Dry::CLI::Command
|
|
13
|
+
desc "Publish every package in the family to RubyGems and npm"
|
|
14
|
+
|
|
15
|
+
option :dry_run,
|
|
16
|
+
type: :boolean,
|
|
17
|
+
default: false,
|
|
18
|
+
desc: "Print the commands that would run; publish nothing"
|
|
19
|
+
|
|
20
|
+
option :force,
|
|
21
|
+
type: :boolean,
|
|
22
|
+
default: false,
|
|
23
|
+
desc: "Attempt every package, even ones already on their registry"
|
|
24
|
+
|
|
25
|
+
example [
|
|
26
|
+
" # publish whatever is not out yet",
|
|
27
|
+
"--dry-run # print what would happen, touch no registry",
|
|
28
|
+
"--force # skip the already-published check"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Idempotent by default: a package whose version is already on its
|
|
32
|
+
# registry is skipped, so re-running after a mid-release failure
|
|
33
|
+
# resumes rather than throwing rejections at RubyGems and npm.
|
|
34
|
+
#
|
|
35
|
+
# @param options [Hash] parsed CLI options
|
|
36
|
+
# @return [void]
|
|
37
|
+
def call(**options)
|
|
38
|
+
ok = Inquirex::Tools::Publisher.new.call(
|
|
39
|
+
dry_run: options.fetch(:dry_run, false),
|
|
40
|
+
force: options.fetch(:force, false)
|
|
41
|
+
)
|
|
42
|
+
exit(1) unless ok
|
|
43
|
+
rescue Inquirex::Tools::Error => e
|
|
44
|
+
warn "ERROR: #{e.message}"
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Tools
|
|
5
|
+
module Commands
|
|
6
|
+
# `inquirex versions-bump` — moves every lockstep package to one version.
|
|
7
|
+
#
|
|
8
|
+
# The write counterpart of `inquirex versions-check`. Separate commands
|
|
9
|
+
# rather than one command with a `--set` flag, so nothing that writes can
|
|
10
|
+
# be reached by accident from a command meant to report.
|
|
11
|
+
class VersionsBump < Dry::CLI::Command
|
|
12
|
+
desc "Set every lockstep package to one version"
|
|
13
|
+
|
|
14
|
+
argument :version,
|
|
15
|
+
type: :string,
|
|
16
|
+
required: true,
|
|
17
|
+
desc: "Target version, MAJOR.MINOR.PATCH — must not go backwards"
|
|
18
|
+
|
|
19
|
+
example [
|
|
20
|
+
"0.9.0 # move every lockstep package to 0.9.0"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# @param version [String, nil] target version
|
|
24
|
+
# @return [void]
|
|
25
|
+
def call(version: nil, **)
|
|
26
|
+
exit(1) unless Inquirex::Tools::VersionBumper.new.call(version)
|
|
27
|
+
rescue Inquirex::Tools::Error => e
|
|
28
|
+
warn "ERROR: #{e.message}"
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Tools
|
|
5
|
+
module Commands
|
|
6
|
+
# `inquirex versions-check` — reports the family's lockstep version.
|
|
7
|
+
#
|
|
8
|
+
# Plural on purpose: `inquirex version` prints this tool's own version,
|
|
9
|
+
# `inquirex versions-check` reports the family's. Read-only; the write
|
|
10
|
+
# counterpart is `inquirex versions-bump`.
|
|
11
|
+
class VersionsCheck < Dry::CLI::Command
|
|
12
|
+
desc "Report lockstep version parity across the family"
|
|
13
|
+
|
|
14
|
+
option :preflight,
|
|
15
|
+
type: :boolean,
|
|
16
|
+
default: false,
|
|
17
|
+
desc: "Also check clean trees, main branch, and tags"
|
|
18
|
+
|
|
19
|
+
example [
|
|
20
|
+
" # report parity, exit 1 on drift",
|
|
21
|
+
"--preflight # release readiness"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# @param preflight [Boolean] also report git release readiness
|
|
25
|
+
# @return [void]
|
|
26
|
+
def call(preflight: false, **)
|
|
27
|
+
checker = Inquirex::Tools::VersionChecker.new
|
|
28
|
+
exit(1) unless preflight ? checker.preflight : checker.call
|
|
29
|
+
rescue Inquirex::Tools::Error => e
|
|
30
|
+
warn "ERROR: #{e.message}"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
require "dry/cli"
|
|
4
4
|
|
|
5
5
|
require_relative "commands/version"
|
|
6
|
-
require_relative "commands/
|
|
6
|
+
require_relative "commands/versions_bump"
|
|
7
|
+
require_relative "commands/versions_check"
|
|
8
|
+
require_relative "commands/publish"
|
|
9
|
+
require_relative "commands/create_release"
|
|
7
10
|
require_relative "commands/changelogs"
|
|
8
11
|
|
|
9
12
|
module Inquirex
|
|
@@ -16,9 +19,14 @@ module Inquirex
|
|
|
16
19
|
module Commands
|
|
17
20
|
extend Dry::CLI::Registry
|
|
18
21
|
|
|
19
|
-
register "versions",
|
|
22
|
+
register "versions-check", VersionsCheck
|
|
23
|
+
register "versions-bump", VersionsBump
|
|
24
|
+
register "publish", Publish
|
|
25
|
+
# `publish-all` reads naturally next to the justfile recipe of that name.
|
|
26
|
+
register "publish-all", Publish
|
|
27
|
+
register "create-release", CreateRelease
|
|
20
28
|
register "changelogs", Changelogs
|
|
21
|
-
register "version",
|
|
29
|
+
register "version", Version
|
|
22
30
|
end
|
|
23
31
|
end
|
|
24
32
|
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "colored2"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "workspace"
|
|
7
|
+
|
|
8
|
+
module Inquirex
|
|
9
|
+
module Tools
|
|
10
|
+
# Publishes every package in the family by running each one's own
|
|
11
|
+
# `just publish` in its own directory.
|
|
12
|
+
#
|
|
13
|
+
# How a package reaches its registry stays that package's business: the
|
|
14
|
+
# gems build a .gem and `gem push` it, the npm packages run bun or yarn.
|
|
15
|
+
# This class only decides the order, supplies the one thing a package
|
|
16
|
+
# cannot work out for itself — a fresh 2FA code — and stops at the first
|
|
17
|
+
# failure.
|
|
18
|
+
#
|
|
19
|
+
# **A TOTP is single-use.** Reading 1Password once per gem inside a
|
|
20
|
+
# 30-second window returns the same six digits every time, and RubyGems
|
|
21
|
+
# rejects the second push as a replay. So a code is read per gem and the
|
|
22
|
+
# run waits for it to actually rotate before moving on, which is why
|
|
23
|
+
# publishing four gems takes minutes rather than seconds. The npm packages
|
|
24
|
+
# read their own code and are called with no argument.
|
|
25
|
+
#
|
|
26
|
+
# Order comes from {Workspace::LOCKSTEP}, so adding a package to the family
|
|
27
|
+
# adds it to the release. That order puts the core gem first, which matters:
|
|
28
|
+
# inquirex-llm and inquirex-tty depend on it and cannot resolve until it is
|
|
29
|
+
# on RubyGems.
|
|
30
|
+
#
|
|
31
|
+
# @example Preview a release without touching a registry
|
|
32
|
+
# Inquirex::Tools::Publisher.new.call(dry_run: true)
|
|
33
|
+
class Publisher
|
|
34
|
+
# How long to wait for a TOTP to rotate before giving up and using what
|
|
35
|
+
# 1Password last returned. Codes rotate every 30 seconds; 90 covers a
|
|
36
|
+
# window boundary landing badly without hanging a release indefinitely.
|
|
37
|
+
ROTATE_TIMEOUT = 90
|
|
38
|
+
|
|
39
|
+
# Seconds between polls of 1Password while waiting for a new code.
|
|
40
|
+
ROTATE_INTERVAL = 2
|
|
41
|
+
|
|
42
|
+
# Shells out with the child's stdout and stderr left attached, so a
|
|
43
|
+
# failing package's own output is what the operator sees.
|
|
44
|
+
DEFAULT_RUNNER = lambda { |dir, argv|
|
|
45
|
+
Dir.chdir(dir) { system("just", *argv) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Reads the current RubyGems TOTP. `op` resolves the account from
|
|
49
|
+
# OP_ACCOUNT, which `just publish-all` exports by decrypting
|
|
50
|
+
# .env.encrypted. Returns nil when unavailable, so a release without
|
|
51
|
+
# 1Password degrades to `gem push` prompting rather than failing.
|
|
52
|
+
DEFAULT_OTP_READER = lambda {
|
|
53
|
+
ref = "op://open-source-repos/ruby-gems/one-time password?attribute=otp"
|
|
54
|
+
code = `op read #{ref.inspect} 2>/dev/null`.strip
|
|
55
|
+
code.empty? ? nil : code
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Asks the registry whether a version is already public.
|
|
59
|
+
#
|
|
60
|
+
# Returns nil rather than false when the answer cannot be obtained — no
|
|
61
|
+
# network, a 500, a timeout. The caller treats nil as "attempt it": the
|
|
62
|
+
# registry itself refuses a duplicate, so guessing "not published" and
|
|
63
|
+
# trying is safe, whereas guessing "published" would silently skip a
|
|
64
|
+
# package that never shipped.
|
|
65
|
+
DEFAULT_PUBLISHED_CHECKER = lambda { |name, kind, version|
|
|
66
|
+
url = case kind
|
|
67
|
+
when :gem then "https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json"
|
|
68
|
+
when :npm then "https://registry.npmjs.org/#{name}/#{version}"
|
|
69
|
+
end
|
|
70
|
+
begin
|
|
71
|
+
uri = URI.parse(url)
|
|
72
|
+
response = Net::HTTP.start(uri.host,
|
|
73
|
+
uri.port,
|
|
74
|
+
use_ssl: true,
|
|
75
|
+
open_timeout: 5,
|
|
76
|
+
read_timeout: 5) do |http|
|
|
77
|
+
http.head(uri.request_uri)
|
|
78
|
+
end
|
|
79
|
+
case response
|
|
80
|
+
when Net::HTTPSuccess then true
|
|
81
|
+
when Net::HTTPNotFound then false
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# @param workspace [Workspace] the ecosystem checkout to publish from
|
|
89
|
+
# @param out [IO] where report output goes
|
|
90
|
+
# @param runner [#call] invoked with (dir, argv) to run a command; the
|
|
91
|
+
# default shells out. Injected so specs can assert what would run
|
|
92
|
+
# without publishing anything.
|
|
93
|
+
# @param otp_reader [#call] returns the current 2FA code, or nil
|
|
94
|
+
# @param published_checker [#call] (name, kind, version) => true/false/nil
|
|
95
|
+
def initialize(workspace: Workspace.new,
|
|
96
|
+
out: $stdout,
|
|
97
|
+
runner: DEFAULT_RUNNER,
|
|
98
|
+
otp_reader: DEFAULT_OTP_READER,
|
|
99
|
+
published_checker: DEFAULT_PUBLISHED_CHECKER)
|
|
100
|
+
@workspace = workspace
|
|
101
|
+
@out = out
|
|
102
|
+
@runner = runner
|
|
103
|
+
@otp_reader = otp_reader
|
|
104
|
+
@published_checker = published_checker
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Publishes every lockstep package, or prints what it would publish.
|
|
108
|
+
#
|
|
109
|
+
# Idempotent: a package whose current version is already on its registry
|
|
110
|
+
# is skipped, so re-running after a mid-release failure picks up exactly
|
|
111
|
+
# where it stopped instead of throwing six rejections at two registries.
|
|
112
|
+
#
|
|
113
|
+
# @param dry_run [Boolean] print the commands and run nothing
|
|
114
|
+
# @param args [Array<String>] extra arguments forwarded verbatim to each
|
|
115
|
+
# package's `just publish`
|
|
116
|
+
# @param force [Boolean] attempt every package even when it looks published
|
|
117
|
+
# @return [Boolean] true when every package published, skipped or printed
|
|
118
|
+
def call(dry_run: false, args: [], force: false)
|
|
119
|
+
out.puts(dry_run ? "DRY RUN — these commands would run, in this order:" : "Publishing the family:")
|
|
120
|
+
|
|
121
|
+
last_otp = nil
|
|
122
|
+
published = 0
|
|
123
|
+
skipped = 0
|
|
124
|
+
|
|
125
|
+
Workspace::LOCKSTEP.each do |name, kind|
|
|
126
|
+
dir = File.join(workspace.root, name)
|
|
127
|
+
raise Error, "no checkout for #{name} at #{dir}" unless Dir.exist?(dir)
|
|
128
|
+
|
|
129
|
+
version = workspace.version_of(name)
|
|
130
|
+
if !force && version && published_checker.call(name, kind, version)
|
|
131
|
+
row(name, kind, "already #{version} on #{registry(kind)} — skipping".dark)
|
|
132
|
+
skipped += 1
|
|
133
|
+
next
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
argv = ["publish", *args]
|
|
137
|
+
if dry_run
|
|
138
|
+
row(name, kind, "just #{argv.join(" ")}#{" (#{version})" if version}")
|
|
139
|
+
published += 1
|
|
140
|
+
next
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Only the gems take a code: each npm package reads its own.
|
|
144
|
+
if kind == :gem
|
|
145
|
+
last_otp = fresh_otp(last_otp)
|
|
146
|
+
argv += [last_otp] if last_otp
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
out.puts
|
|
150
|
+
out.puts "━━ #{name} (#{kind}) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
151
|
+
return false unless run(name, dir, argv)
|
|
152
|
+
|
|
153
|
+
published += 1
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
out.puts
|
|
157
|
+
out.puts summary(dry_run:, published:, skipped:)
|
|
158
|
+
true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# @return [Workspace] the ecosystem checkout being published
|
|
164
|
+
attr_reader :workspace
|
|
165
|
+
|
|
166
|
+
# @return [IO] where report output goes
|
|
167
|
+
attr_reader :out
|
|
168
|
+
|
|
169
|
+
# @return [#call] command runner
|
|
170
|
+
attr_reader :runner
|
|
171
|
+
|
|
172
|
+
# @return [#call] source of 2FA codes
|
|
173
|
+
attr_reader :otp_reader
|
|
174
|
+
|
|
175
|
+
# @return [#call] asks a registry whether a version is already public
|
|
176
|
+
attr_reader :published_checker
|
|
177
|
+
|
|
178
|
+
# @return [Boolean] whether the package published cleanly
|
|
179
|
+
def run(name, dir, argv)
|
|
180
|
+
return true if runner.call(dir, argv)
|
|
181
|
+
|
|
182
|
+
out.puts
|
|
183
|
+
out.puts "#{"ERROR".bold.red}: #{name} failed `just #{argv.first}` — stopping here."
|
|
184
|
+
out.puts "Publishing is irreversible, so the packages after #{name} were left alone."
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Blocks until 1Password returns a code that has not already been spent.
|
|
189
|
+
#
|
|
190
|
+
# @param last [String, nil] the code the previous gem used
|
|
191
|
+
# @return [String, nil] a fresh code, or nil when 1Password is unavailable
|
|
192
|
+
def fresh_otp(last)
|
|
193
|
+
waited = 0
|
|
194
|
+
loop do
|
|
195
|
+
code = otp_reader.call
|
|
196
|
+
return nil if code.nil?
|
|
197
|
+
return code if code != last
|
|
198
|
+
return code if waited >= ROTATE_TIMEOUT
|
|
199
|
+
|
|
200
|
+
out.puts " waiting for the 2FA code to rotate…" if waited.zero?
|
|
201
|
+
sleep ROTATE_INTERVAL
|
|
202
|
+
waited += ROTATE_INTERVAL
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Pads before colorizing: an ANSI escape is several characters wide to
|
|
207
|
+
# `format` and zero columns wide on screen, so colorizing first left the
|
|
208
|
+
# table ragged by exactly the length of the escape codes.
|
|
209
|
+
#
|
|
210
|
+
# @return [void]
|
|
211
|
+
def row(name, kind, detail)
|
|
212
|
+
out.puts " #{name.ljust(18).yellow} #{kind.to_s.ljust(4).cyan} #{detail}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [String] human-readable registry name for a package kind
|
|
216
|
+
def registry(kind) = kind == :gem ? "RubyGems" : "npm"
|
|
217
|
+
|
|
218
|
+
# @return [String] the closing line, which has to distinguish "nothing to
|
|
219
|
+
# do because everything is already out" from "nothing happened"
|
|
220
|
+
def summary(dry_run:, published:, skipped:)
|
|
221
|
+
return "Everything is already published — nothing to do.".green if published.zero? && skipped.positive?
|
|
222
|
+
|
|
223
|
+
counted = "#{published} package#{"s" unless published == 1}"
|
|
224
|
+
note = skipped.positive? ? ", #{skipped} already published" : ""
|
|
225
|
+
dry_run ? "Would publish #{counted}#{note}. Nothing was published." : "Published #{counted}#{note}.".green
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "colored2"
|
|
4
|
+
require_relative "workspace"
|
|
5
|
+
|
|
6
|
+
module Inquirex
|
|
7
|
+
module Tools
|
|
8
|
+
# Cuts a GitHub release for every package in the family by running each
|
|
9
|
+
# one's own `just release` in its own directory.
|
|
10
|
+
#
|
|
11
|
+
# The counterpart to publishing: that puts packages on RubyGems and npm,
|
|
12
|
+
# this puts a `vX.Y.Z` tag and a GitHub release against the commit that
|
|
13
|
+
# shipped. Every package already owns a `release` recipe — it tags,
|
|
14
|
+
# force-pushes the tag, and recreates the GitHub release with generated
|
|
15
|
+
# notes — so how a repository releases stays that repository's business.
|
|
16
|
+
# What this adds is the order, the guards, and one command instead of six.
|
|
17
|
+
#
|
|
18
|
+
# Unlike publishing, a release is **reversible**: a tag can be moved and a
|
|
19
|
+
# GitHub release deleted, which is exactly what each recipe does on every
|
|
20
|
+
# run. That is why this is safe to re-run, and why a mid-run failure can be
|
|
21
|
+
# fixed and the command simply issued again.
|
|
22
|
+
#
|
|
23
|
+
# The guards exist because the recipe tags `HEAD`. Releasing from a dirty
|
|
24
|
+
# tree tags a commit that does not match what was tested; releasing from a
|
|
25
|
+
# feature branch tags work that was never merged. Both produce a `v0.9.5`
|
|
26
|
+
# pointing somewhere nobody can reproduce, and neither is fixed by
|
|
27
|
+
# re-running — so they are refused rather than warned about.
|
|
28
|
+
#
|
|
29
|
+
# @example Preview a release without tagging anything
|
|
30
|
+
# Inquirex::Tools::Releaser.new.call(dry_run: true)
|
|
31
|
+
class Releaser
|
|
32
|
+
# The only branch a release may be cut from. A tag on anything else
|
|
33
|
+
# points at work that never went through review.
|
|
34
|
+
RELEASE_BRANCH = "main"
|
|
35
|
+
|
|
36
|
+
# Shells out with the child's stdout and stderr left attached, so a
|
|
37
|
+
# failing package's own `gh` output is what the operator sees.
|
|
38
|
+
DEFAULT_RUNNER = lambda { |dir, argv|
|
|
39
|
+
Dir.chdir(dir) { system("just", *argv) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# @param workspace [Workspace] the ecosystem checkout to release from
|
|
43
|
+
# @param out [IO] where report output goes
|
|
44
|
+
# @param runner [#call] invoked with (dir, argv) to run a command; the
|
|
45
|
+
# default shells out. Injected so specs can assert what would run
|
|
46
|
+
# without creating a tag or a GitHub release.
|
|
47
|
+
def initialize(workspace: Workspace.new, out: $stdout, runner: DEFAULT_RUNNER)
|
|
48
|
+
@workspace = workspace
|
|
49
|
+
@out = out
|
|
50
|
+
@runner = runner
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Creates a GitHub release for every lockstep package.
|
|
54
|
+
#
|
|
55
|
+
# Stops at the first package that fails or is refused, and says so. The
|
|
56
|
+
# packages after it are left alone rather than half-tagged.
|
|
57
|
+
#
|
|
58
|
+
# @param dry_run [Boolean] print what would run; tag nothing
|
|
59
|
+
# @param force [Boolean] release despite a dirty tree or a non-main branch
|
|
60
|
+
# @param args [Array<String>] extra arguments forwarded to `just release`
|
|
61
|
+
# @return [Boolean] true when every package released or was printed
|
|
62
|
+
def call(dry_run: false, force: false, args: [])
|
|
63
|
+
out.puts(dry_run ? "DRY RUN — these releases would be created:" : "Creating GitHub releases for the family:")
|
|
64
|
+
|
|
65
|
+
released = 0
|
|
66
|
+
|
|
67
|
+
Workspace::LOCKSTEP.each_key do |name|
|
|
68
|
+
dir = File.join(workspace.root, name)
|
|
69
|
+
raise Error, "no checkout for #{name} at #{dir}" unless Dir.exist?(dir)
|
|
70
|
+
|
|
71
|
+
version = workspace.version_of(name)
|
|
72
|
+
|
|
73
|
+
blocker = force ? nil : blocker_for(name)
|
|
74
|
+
if blocker
|
|
75
|
+
row(name, version, blocker.red)
|
|
76
|
+
return refused(name)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
argv = ["release", *args]
|
|
80
|
+
if dry_run
|
|
81
|
+
row(name, version, "just #{argv.join(" ")}")
|
|
82
|
+
released += 1
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
out.puts
|
|
87
|
+
out.puts "━━ #{name} v#{version} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
88
|
+
return false unless run(name, dir, argv)
|
|
89
|
+
|
|
90
|
+
released += 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
out.puts
|
|
94
|
+
out.puts summary(dry_run:, released:)
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# @return [Workspace] the ecosystem checkout being released
|
|
101
|
+
attr_reader :workspace
|
|
102
|
+
|
|
103
|
+
# @return [IO] where report output goes
|
|
104
|
+
attr_reader :out
|
|
105
|
+
|
|
106
|
+
# @return [#call] command runner
|
|
107
|
+
attr_reader :runner
|
|
108
|
+
|
|
109
|
+
# Why this package must not be released, or nil when it is fine.
|
|
110
|
+
#
|
|
111
|
+
# A `clean` of nil means the directory is not a git checkout at all. That
|
|
112
|
+
# is left for the recipe to fail on rather than pre-judged here: a state
|
|
113
|
+
# that cannot be read should never silently skip a package, because
|
|
114
|
+
# "unknown" and "not releasable" are different answers.
|
|
115
|
+
#
|
|
116
|
+
# @param name [String] package name
|
|
117
|
+
# @return [String, nil] human-readable blocker
|
|
118
|
+
def blocker_for(name)
|
|
119
|
+
state = workspace.git_state(name)
|
|
120
|
+
return "working tree is dirty — the tag would not match what was tested" if state[:clean] == false
|
|
121
|
+
|
|
122
|
+
branch = state[:branch]
|
|
123
|
+
return nil if branch.nil? || branch.empty? || branch == RELEASE_BRANCH
|
|
124
|
+
|
|
125
|
+
"on #{branch}, not #{RELEASE_BRANCH} — the tag would point at unmerged work"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param name [String] the package that was refused
|
|
129
|
+
# @return [Boolean] always false, so the caller can `return refused(name)`
|
|
130
|
+
def refused(name)
|
|
131
|
+
out.puts
|
|
132
|
+
out.puts "#{"ERROR".bold.red}: #{name} is not releasable — stopping here."
|
|
133
|
+
out.puts "Nothing was tagged. Fix the checkout, or re-run with --force to override."
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [Boolean] whether the package released cleanly
|
|
138
|
+
def run(name, dir, argv)
|
|
139
|
+
return true if runner.call(dir, argv)
|
|
140
|
+
|
|
141
|
+
out.puts
|
|
142
|
+
out.puts "#{"ERROR".bold.red}: #{name} failed `just #{argv.first}` — stopping here."
|
|
143
|
+
out.puts "A release is re-runnable, so fix the cause and issue the command again."
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Pads before colorizing: an ANSI escape is several characters wide to
|
|
148
|
+
# `String#ljust` and zero columns wide on screen, so colorizing first left
|
|
149
|
+
# the table ragged by exactly the length of the escape codes.
|
|
150
|
+
#
|
|
151
|
+
# @return [void]
|
|
152
|
+
def row(name, version, detail)
|
|
153
|
+
out.puts " #{name.ljust(18).yellow} #{(version || "?").ljust(8).cyan} #{detail}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [String] the closing line
|
|
157
|
+
def summary(dry_run:, released:)
|
|
158
|
+
counted = "#{released} release#{"s" unless released == 1}"
|
|
159
|
+
dry_run ? "Would create #{counted}. Nothing was tagged." : "Created #{counted}.".green
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|