inquirex-tools 0.9.4 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a86b68b3b9abc13c3046754f8b5f43a69f84360e3b581e5760ab9145049da76a
4
- data.tar.gz: 5001830e113f7c7b63547f9f49ca10cb290b946c90a0916313e83dee0e1f479a
3
+ metadata.gz: d8f0e3e2dc5bcc9d45d3c9cba64f8a7d96400b6d1296d316ff56460ece229166
4
+ data.tar.gz: 3ee07acd2e8e731115e0a5552bbbdaeea2979198195e396fc946765f8b6deef6
5
5
  SHA512:
6
- metadata.gz: ab2d90315649ab9d2465156964f6436b96a79a5fb0a32c4489ef2910d8a991166da17bcd1731bae08d830097bb2249b91ca9c7ad339e171d48004b812363c8f7
7
- data.tar.gz: f7258bdb64b70a46a21b09ccb0f58111077e326b290fdaa3fc72e5cfea1c661abe546e005bce892ec69662fed81c938cbf1645a9cad4eb8e61f0061b56ff721f
6
+ metadata.gz: 81ca8eed0f60037439fd3c8ab6aafe9b35114082f2c02db488bf279a3d52577c7fb7c8c7e04a41ab48e0c8ab6be1e8e71e324d32b566cbc865ce861e4855601e
7
+ data.tar.gz: 3adc9867dab085361badf7b00ff38ef9965345efa2ef610bddb27e35e2a34945ebaa2459bdf068e18118cd2121ec67f952564f2f9df562e00d53df49ebb63578
@@ -0,0 +1,21 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="99" height="20">
3
+ <linearGradient id="b" x2="0" y2="100%">
4
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
5
+ <stop offset="1" stop-opacity=".1"/>
6
+ </linearGradient>
7
+ <mask id="a">
8
+ <rect width="99" height="20" rx="3" fill="#fff"/>
9
+ </mask>
10
+ <g mask="url(#a)">
11
+ <path fill="#555" d="M0 0h63v20H0z"/>
12
+ <path fill="#4c1" d="M63 0h36v20H63z"/>
13
+ <path fill="url(#b)" d="M0 0h99v20H0z"/>
14
+ </g>
15
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
16
+ <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
17
+ <text x="31.5" y="14">coverage</text>
18
+ <text x="80" y="15" fill="#010101" fill-opacity=".3">97%</text>
19
+ <text x="80" y="14">97%</text>
20
+ </g>
21
+ </svg>
@@ -0,0 +1,163 @@
1
+ # Monorepo Migration Plan
2
+
3
+ Status: **proposed**, not started. Execute only after the open PRs listed under [Sequencing](#sequencing) have merged.
4
+
5
+ ## Why
6
+
7
+ The Inquirex ecosystem implements the same semantics in two languages and enforces agreement by convention. Three independent implementations of "canonicalize an LLM answer against a question's allowed values" exist today:
8
+
9
+ | Implementation | Repo | Exact | Case-insensitive value | Label fallback | Strips whitespace |
10
+ | ------------------------- | --------------- | ----- | ---------------------- | -------------- | ----------------- |
11
+ | `Node#resolve_option` | inquirex | yes | yes | yes | no |
12
+ | `Adapter#canonical_value` | inquirex-llm | yes | yes | **no** | **yes** |
13
+ | `canonicalOption` | inquirex-widget | yes | yes | yes | **no** |
14
+
15
+ They disagree on real inputs. `"US citizen or permanent resident"` resolves through the gem and the widget but returns `nil` through the LLM adapter. `" us_person "` resolves only through the LLM adapter. Nothing in CI can observe this, because no CI job sees more than one of them.
16
+
17
+ This is the failure mode the family already has scar tissue for. The gem shipped `required false` in 0.7.0 and no consumer knew, because a verb present in one package and missing in another does not fail loudly — it silently drops data. The lockstep version rule exists to make "which combination has this bug?" a lookup rather than an investigation.
18
+
19
+ The rule is not actually enforced. Every inter-family gemspec declares `~> 0.6`, which admits any 0.x from 0.6 upward. `inquirex-llm`'s checked-in `Gemfile.lock` currently resolves `inquirex-llm (0.9.2)` against `inquirex (0.8.0)`.
20
+
21
+ A monorepo does not fix divergence by itself. It makes the fix — one shared fixture set, executed by every implementation in one CI run — cheap enough to actually build.
22
+
23
+ ## Scope
24
+
25
+ **In**, as one public repository:
26
+
27
+ - `inquirex` — the DSL, engine, and rule AST
28
+ - `inquirex-llm` — LLM verbs
29
+ - `inquirex-widget` — the embeddable TypeScript widget
30
+ - `inquirex-tty` — terminal adapter
31
+ - `inquirex-tools` — release and version tooling (largely absorbed; see below)
32
+
33
+ **Out**, staying as they are:
34
+
35
+ - `qualified-at` — the commercial Rails application. Private, its own release cadence, and it consumes the libraries as published packages. Folding a revenue product into the open-source repository forces a visibility decision on the whole tree and buys nothing.
36
+ - `inquirex-webui` — private, React, a distinct product (visual builder). Revisit once the library tree has settled.
37
+ - `inquirex-presentation`, `inquirex-slides`, `claude-setup` — unrelated artifacts.
38
+
39
+ The in-scope set is exactly the packages bound by the lockstep rule, minus `inquirex-webui`, plus `inquirex-tty` (which PR #2 in this repo brings into lockstep).
40
+
41
+ ## Target layout
42
+
43
+ ```
44
+ inquirex/
45
+ packages/
46
+ inquirex/ # gem: DSL, engine, rules
47
+ inquirex-llm/ # gem: LLM verbs
48
+ inquirex-tty/ # gem: terminal adapter
49
+ inquirex-widget/ # npm: TypeScript widget
50
+ conformance/
51
+ fixtures/ # language-neutral JSON cases
52
+ ruby/ # runner: executes fixtures against the gems
53
+ typescript/ # runner: executes fixtures against the widget
54
+ tools/ # release tooling (from inquirex-tools)
55
+ justfile # top-level recipes; per-package justfiles remain
56
+ .github/workflows/
57
+ ```
58
+
59
+ Each package keeps its own gemspec / package.json, test suite, and justfile. The monorepo adds a layer above them, it does not merge their build systems.
60
+
61
+ ## Migration mechanics
62
+
63
+ History is preserved. Do not squash the repositories into a single import commit.
64
+
65
+ For each repository, in the order listed under Sequencing:
66
+
67
+ ```bash
68
+ # 1. Clone a scratch copy — never run filter-repo against your working checkout
69
+ git clone git@github.com:inquirex/<pkg>.git /tmp/mono/<pkg>
70
+ cd /tmp/mono/<pkg>
71
+
72
+ # 2. Rewrite every path to sit under packages/<pkg>/
73
+ git filter-repo --to-subdirectory-filter packages/<pkg>
74
+
75
+ # 3. Graft it into the monorepo as an unrelated history
76
+ cd /path/to/inquirex-monorepo
77
+ git remote add <pkg> /tmp/mono/<pkg>
78
+ git fetch <pkg>
79
+ git merge --allow-unrelated-histories <pkg>/main -m "Import <pkg> under packages/"
80
+ git remote remove <pkg>
81
+ ```
82
+
83
+ `git filter-repo` requires a fresh clone and refuses to run on a repo with an origin unless forced — that is a guardrail, not an obstacle. Install with `brew install git-filter-repo`.
84
+
85
+ Path rewriting means `git log --follow packages/inquirex/lib/inquirex/node.rb` walks the full pre-migration history. Blame survives. Existing tags survive but become ambiguous across packages — retag as `<pkg>/vX.Y.Z` after the import, or accept that pre-migration tags refer to a single package's history.
86
+
87
+ ### What happens to the old repositories
88
+
89
+ Archive, do not delete. Each keeps its issues, its pull requests, and its clone URLs. GitHub archived repos stay readable and redirect nothing, so add a line to each README pointing at the monorepo path. RubyGems and npm metadata should be updated at the next release to point `source_code_uri` / `repository.url` at `inquirex/inquirex` with the appropriate `directory`.
90
+
91
+ Both npm and RubyGems support a subdirectory hint:
92
+
93
+ ```jsonc
94
+ // packages/inquirex-widget/package.json
95
+ "repository": { "type": "git", "url": "git+https://github.com/inquirex/inquirex.git", "directory": "packages/inquirex-widget" }
96
+ ```
97
+
98
+ ```ruby
99
+ # packages/inquirex/inquirex.gemspec
100
+ spec.metadata["source_code_uri"] = "https://github.com/inquirex/inquirex/tree/main/packages/inquirex"
101
+ ```
102
+
103
+ ## Versioning and release
104
+
105
+ One version number for the tree, which is what the lockstep rule always wanted. A single `VERSION` file at the root; each gemspec and `package.json` reads from it at build time.
106
+
107
+ This deletes most of `inquirex-tools`: cross-repo version checking and bumping becomes `just version <increment>` operating on one file. Keep the release recipes (tagging, GitHub release, publishing); drop the multi-repo coordination.
108
+
109
+ Fix the constraint hole at the same time. Inter-package dependencies move from `~> 0.6` to `= <VERSION>` or `~> <MAJOR>.<MINOR>`, so bundler cannot resolve a mismatched pair. Do this **in the release that first ships from the monorepo**, not before — tightening `inquirex-llm` to `~> 0.9` while `inquirex` 0.9.x is unpublished breaks `bundle install`.
110
+
111
+ ## CI
112
+
113
+ One workflow with path filters, so a widget-only change does not run the Ruby matrix:
114
+
115
+ - `ruby.yml` — triggers on `packages/inquirex*/**` and `conformance/**`; runs each gem's suite
116
+ - `typescript.yml` — triggers on `packages/inquirex-widget/**` and `conformance/**`; runs `bun test`, typecheck, lint, build
117
+ - `conformance.yml` — triggers on **everything**; runs the shared fixtures against both language runners
118
+
119
+ The third workflow is the point of the exercise. It must not be path-filtered.
120
+
121
+ ## The conformance suite
122
+
123
+ A fixture is a JSON document describing a flow, a sequence of inputs, and the expected resulting state:
124
+
125
+ ```jsonc
126
+ {
127
+ "name": "extraction resolves a label to its form value",
128
+ "flow": { "...": "flow definition" },
129
+ "steps": [
130
+ { "extract": { "residency_status": "US citizen or permanent resident" } }
131
+ ],
132
+ "expect": {
133
+ "answers": { "residency_status": "us_person" },
134
+ "current_step": "income_types",
135
+ "suggestions": {}
136
+ }
137
+ }
138
+ ```
139
+
140
+ Each language ships a thin runner that loads a fixture, drives its own engine, and asserts the expected state. Adding a case means adding one JSON file; both languages are then obliged to agree or fail.
141
+
142
+ Seed it with the divergence table above — those are three cases that fail today. Then extend to transition resolution, `skip_if`, accumulators, and multi-select suggestion handling.
143
+
144
+ This is the deliverable that justifies the migration. Everything else is filing.
145
+
146
+ ## Sequencing
147
+
148
+ 1. Merge the open work first. Migrating on top of open pull requests strands them: `inquirex-widget` #6-#9, `inquirex` #5/#6, `inquirex-llm` #3, `inquirex-tools` #1/#2, `inquirex-tty` #10. Close or rebase the stale ones (`inquirex-tty` #6 is conflicting and failing; `inquirex` #5 and `inquirex-tools` #1 appear superseded).
149
+ 1. Publish 0.9.2 from the existing repositories, so a known-good release exists on both registries before anything moves.
150
+ 1. Create the monorepo and import packages in dependency order: `inquirex`, `inquirex-llm`, `inquirex-tty`, `inquirex-widget`, `inquirex-tools`.
151
+ 1. Wire up the top-level justfile and per-language CI. Prove every existing suite still passes unchanged.
152
+ 1. Build the conformance harness with the three known-divergent cases. Watch them fail.
153
+ 1. Reconcile the three implementations. Decide the canonical rules — label fallback yes or no, whitespace stripping yes or no — and make all three obey.
154
+ 1. Move to a single root `VERSION`, tighten the inter-package constraints, release 0.10.0 from the monorepo.
155
+ 1. Archive the old repositories with a pointer in each README.
156
+
157
+ Steps 1-2 are prerequisites. Steps 3-4 are reversible: if the imported tree is unsatisfactory, delete it — the original repositories are untouched until step 8.
158
+
159
+ ## Open decisions
160
+
161
+ - **`inquirex-webui`** — in or out. It is bound by the lockstep rule but is private and a different product. Leaving it out means the rule stays partly manual.
162
+ - **Visibility** — the monorepo must be public for the gems to remain credible open source. That is only tenable if nothing private is imported, which the scope above respects.
163
+ - **Canonical matching rules** — the reconciliation in step 6 is a semantics decision, not a mechanical one. Whitespace stripping is almost certainly correct everywhere. The label fallback is the real question: it is most needed in `inquirex-llm`, which is the one implementation lacking it.
@@ -18,11 +18,29 @@ module Inquirex
18
18
  # Never overwritten by generation, in any repo.
19
19
  PROTECTED = %w[INQUIREX_VERSION_LOG.md].freeze
20
20
 
21
+ # Reads a GitHub token from the `gh` session rather than a stored secret,
22
+ # so nothing here handles a credential in plain text.
23
+ DEFAULT_TOKEN_READER = -> { `gh auth token 2>/dev/null`.strip }
24
+
25
+ # Runs the generator in one repository, discarding its very chatty output.
26
+ DEFAULT_RUNNER = lambda { |dir, env|
27
+ system(env, "github_changelog_generator", chdir: dir, out: File::NULL, err: File::NULL)
28
+ }
29
+
21
30
  # @param workspace [Workspace]
22
31
  # @param out [IO]
23
- def initialize(workspace: Workspace.new, out: $stdout)
32
+ # @param token_reader [#call] returns a GitHub token, or "" when there is
33
+ # no session. Injected so a spec never needs one.
34
+ # @param runner [#call] invoked with (dir, env) to run the generator.
35
+ # Injected so a spec never shells out to it.
36
+ def initialize(workspace: Workspace.new,
37
+ out: $stdout,
38
+ token_reader: DEFAULT_TOKEN_READER,
39
+ runner: DEFAULT_RUNNER)
24
40
  @workspace = workspace
25
41
  @out = out
42
+ @token_reader = token_reader
43
+ @runner = runner
26
44
  end
27
45
 
28
46
  # @return [Array<String>] every repo carrying a generator config
@@ -41,7 +59,7 @@ module Inquirex
41
59
  # @param names [Array<String>] repositories, or all configured when empty
42
60
  # @return [Boolean] true when every repository succeeded
43
61
  def generate(*names)
44
- token = `gh auth token 2>/dev/null`.strip
62
+ token = @token_reader.call.to_s.strip
45
63
  if token.empty?
46
64
  @out.puts "ERROR: `gh auth token` returned nothing — run `gh auth login`"
47
65
  return false
@@ -69,7 +87,7 @@ module Inquirex
69
87
 
70
88
  @out.print format(" %<name>-16s ", name: name)
71
89
  env = { "CHANGELOG_GITHUB_TOKEN" => token, "RBENV_VERSION" => ENV.fetch("RBENV_VERSION", DEFAULT_RUBY) }
72
- if system(env, "github_changelog_generator", chdir: dir, out: File::NULL, err: File::NULL)
90
+ if @runner.call(dir, env)
73
91
  @out.puts "ok (#{line_count(File.join(dir, "CHANGELOG.md"))} lines)"
74
92
  true
75
93
  else
@@ -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
@@ -17,15 +17,29 @@ module Inquirex
17
17
  default: false,
18
18
  desc: "Print the commands that would run; publish nothing"
19
19
 
20
+ option :force,
21
+ type: :boolean,
22
+ default: false,
23
+ desc: "Attempt every package, even ones already on their registry"
24
+
20
25
  example [
21
- " # publish the whole family",
22
- "--dry-run # print what would happen, touch no registry"
26
+ " # publish whatever is not out yet",
27
+ "--dry-run # print what would happen, touch no registry",
28
+ "--force # skip the already-published check"
23
29
  ]
24
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
+ #
25
35
  # @param options [Hash] parsed CLI options
26
36
  # @return [void]
27
37
  def call(**options)
28
- exit(1) unless Inquirex::Tools::Publisher.new.call(dry_run: options.fetch(:dry_run, false))
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
29
43
  rescue Inquirex::Tools::Error => e
30
44
  warn "ERROR: #{e.message}"
31
45
  exit 1
@@ -6,6 +6,7 @@ require_relative "commands/version"
6
6
  require_relative "commands/versions_bump"
7
7
  require_relative "commands/versions_check"
8
8
  require_relative "commands/publish"
9
+ require_relative "commands/create_release"
9
10
  require_relative "commands/changelogs"
10
11
 
11
12
  module Inquirex
@@ -21,6 +22,9 @@ module Inquirex
21
22
  register "versions-check", VersionsCheck
22
23
  register "versions-bump", VersionsBump
23
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
24
28
  register "changelogs", Changelogs
25
29
  register "version", Version
26
30
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "colored2"
4
+ require "net/http"
5
+ require "uri"
4
6
  require_relative "workspace"
5
7
 
6
8
  module Inquirex
@@ -53,39 +55,88 @@ module Inquirex
53
55
  code.empty? ? nil : code
54
56
  }
55
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
+
56
88
  # @param workspace [Workspace] the ecosystem checkout to publish from
57
89
  # @param out [IO] where report output goes
58
90
  # @param runner [#call] invoked with (dir, argv) to run a command; the
59
91
  # default shells out. Injected so specs can assert what would run
60
92
  # without publishing anything.
61
93
  # @param otp_reader [#call] returns the current 2FA code, or nil
94
+ # @param published_checker [#call] (name, kind, version) => true/false/nil
62
95
  def initialize(workspace: Workspace.new,
63
96
  out: $stdout,
64
97
  runner: DEFAULT_RUNNER,
65
- otp_reader: DEFAULT_OTP_READER)
98
+ otp_reader: DEFAULT_OTP_READER,
99
+ published_checker: DEFAULT_PUBLISHED_CHECKER)
66
100
  @workspace = workspace
67
101
  @out = out
68
102
  @runner = runner
69
103
  @otp_reader = otp_reader
104
+ @published_checker = published_checker
70
105
  end
71
106
 
72
107
  # Publishes every lockstep package, or prints what it would publish.
73
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
+ #
74
113
  # @param dry_run [Boolean] print the commands and run nothing
75
114
  # @param args [Array<String>] extra arguments forwarded verbatim to each
76
115
  # package's `just publish`
77
- # @return [Boolean] true when every package published (or was printed)
78
- def call(dry_run: false, args: [])
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)
79
119
  out.puts(dry_run ? "DRY RUN — these commands would run, in this order:" : "Publishing the family:")
80
120
 
81
121
  last_otp = nil
122
+ published = 0
123
+ skipped = 0
124
+
82
125
  Workspace::LOCKSTEP.each do |name, kind|
83
126
  dir = File.join(workspace.root, name)
84
127
  raise Error, "no checkout for #{name} at #{dir}" unless Dir.exist?(dir)
85
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
+
86
136
  argv = ["publish", *args]
87
137
  if dry_run
88
- row(name, kind, "just #{argv.join(" ")}")
138
+ row(name, kind, "just #{argv.join(" ")}#{" (#{version})" if version}")
139
+ published += 1
89
140
  next
90
141
  end
91
142
 
@@ -98,10 +149,12 @@ module Inquirex
98
149
  out.puts
99
150
  out.puts "━━ #{name} (#{kind}) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
100
151
  return false unless run(name, dir, argv)
152
+
153
+ published += 1
101
154
  end
102
155
 
103
156
  out.puts
104
- out.puts(dry_run ? "Nothing was published." : "Published every package.".green)
157
+ out.puts summary(dry_run:, published:, skipped:)
105
158
  true
106
159
  end
107
160
 
@@ -119,6 +172,9 @@ module Inquirex
119
172
  # @return [#call] source of 2FA codes
120
173
  attr_reader :otp_reader
121
174
 
175
+ # @return [#call] asks a registry whether a version is already public
176
+ attr_reader :published_checker
177
+
122
178
  # @return [Boolean] whether the package published cleanly
123
179
  def run(name, dir, argv)
124
180
  return true if runner.call(dir, argv)
@@ -155,6 +211,19 @@ module Inquirex
155
211
  def row(name, kind, detail)
156
212
  out.puts " #{name.ljust(18).yellow} #{kind.to_s.ljust(4).cyan} #{detail}"
157
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
158
227
  end
159
228
  end
160
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Inquirex
4
4
  module Tools
5
- VERSION = "0.9.4"
5
+ VERSION = "0.9.5"
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "colored2"
4
+ require "open3"
4
5
  require "rubygems/version"
5
6
  require_relative "workspace"
6
7
 
@@ -37,11 +38,43 @@ module Inquirex
37
38
  # Rule across the report, sized to the rows.
38
39
  RULE = ("—" * 52).freeze
39
40
 
41
+ # Bringing a checkout level with origin/main before its suite runs, so a
42
+ # bump is verified against what is actually on main rather than whatever
43
+ # the local clone last saw.
44
+ SYNC_STEPS = [
45
+ %w[fetch --tags origin],
46
+ %w[rebase origin/main]
47
+ ].freeze
48
+
49
+ # Runs one git command, capturing stdout *and* stderr.
50
+ #
51
+ # Capturing is the point. Left attached, a `git` run against a directory
52
+ # that is not a checkout writes "fatal: not a git repository" straight to
53
+ # the terminal — thirty such lines during the specs, which is noise the
54
+ # operator has to learn to ignore. The caller decides what is worth
55
+ # printing.
56
+ #
57
+ # @return [Array(Boolean, String)] success, and the combined output
58
+ DEFAULT_GIT = lambda { |dir, argv|
59
+ output, status = Open3.capture2e("git", "-C", dir, *argv)
60
+ [status.success?, output]
61
+ }
62
+
63
+ # Runs a package's own checks in its own directory.
64
+ DEFAULT_RUNNER = ->(dir, argv) { Dir.chdir(dir) { system(*argv) } }
65
+
40
66
  # @param workspace [Workspace] the ecosystem checkout to rewrite
41
67
  # @param out [IO] where report output goes
42
- def initialize(workspace: Workspace.new, out: $stdout)
68
+ # @param git [#call] invoked with (dir, argv); returns [ok, output].
69
+ # Injected so specs can assert what would run without touching a repo.
70
+ # @param runner [#call] invoked with (dir, argv) to run a package's
71
+ # checks. Injected for the same reason: a unit test must not shell out
72
+ # to six real suites.
73
+ def initialize(workspace: Workspace.new, out: $stdout, git: DEFAULT_GIT, runner: DEFAULT_RUNNER)
43
74
  @workspace = workspace
44
75
  @out = out
76
+ @git = git
77
+ @runner = runner
45
78
  end
46
79
 
47
80
  # Rewrites every lockstep package to the target version, then runs each
@@ -74,6 +107,12 @@ module Inquirex
74
107
  # @return [IO] where report output goes
75
108
  attr_reader :out
76
109
 
110
+ # @return [#call] runs one git command, returning [ok, output]
111
+ attr_reader :git
112
+
113
+ # @return [#call] runs a package's checks, returning truthy on success
114
+ attr_reader :runner
115
+
77
116
  # The one place the package list is walked, so adding a package changes
78
117
  # nothing here.
79
118
  #
@@ -100,25 +139,47 @@ module Inquirex
100
139
  # @return [Boolean, nil] pass/fail, or nil when skipped
101
140
  def check(name)
102
141
  dir = File.join(workspace.root, name)
103
- passed = Dir.chdir(dir) do
104
- row(name, "running #{CHECK.join(" ")} in ".yellow, dir)
105
- out.puts RULE
106
- system("git fetch")
107
- system("test -n $(git status --porcelain) && { git stash -u; echo 'Stashing locally modified files...'; }")
108
- system("git pull origin main")
109
- system("git fetch --tags")
110
- system("git rebase origin/main")
111
- unless File.exist?(File.join(dir, "justfile"))
112
- row(name, "skip".yellow, "no justfile".yellow)
113
- return nil
114
- end
115
- row(name, "Pulled in #{name}, current version is #{system("just version")}", dir)
116
- system(*CHECK)
142
+ unless File.exist?(File.join(dir, "justfile"))
143
+ row(name, "skip".yellow, "no justfile".yellow)
144
+ return nil
117
145
  end
118
- row(name, passed ? "[ OK ]".on.green : "[ 🅧 ]".on_red, dir)
146
+
147
+ sync(name, dir)
148
+ passed = runner.call(dir, CHECK)
149
+ row(name, passed ? "ok".green : "FAIL".red, dir)
119
150
  passed
120
151
  end
121
152
 
153
+ # Brings one package level with origin/main before its suite runs.
154
+ #
155
+ # Two states are reported and left alone rather than acted on:
156
+ #
157
+ # - **not a git checkout** — nothing to sync, and running git here is what
158
+ # printed "fatal: not a git repository" once per command per package.
159
+ # - **uncommitted changes** — the tree is *not* stashed. A version bump
160
+ # that silently runs `git stash -u` can bury work the operator never
161
+ # agreed to move, and the stash stack is shared across worktrees, so it
162
+ # is not even reliably theirs to pop back. Rebasing on top of local
163
+ # edits is equally unwelcome. Skipping is the only non-destructive
164
+ # answer; the checks still run against what is on disk.
165
+ #
166
+ # @param name [String] package name
167
+ # @param dir [String] absolute path to the package
168
+ # @return [void]
169
+ def sync(name, dir)
170
+ state = workspace.git_state(name)
171
+ return row(name, "sync".yellow, "not a git checkout".yellow) if state[:clean].nil?
172
+ return row(name, "sync".yellow, "uncommitted changes — left alone".yellow) unless state[:clean]
173
+
174
+ SYNC_STEPS.each do |argv|
175
+ ok, output = git.call(dir, argv)
176
+ next if ok
177
+
178
+ row(name, "sync".red, "git #{argv.first} failed: #{output.to_s.lines.first&.strip}".red)
179
+ break
180
+ end
181
+ end
182
+
122
183
  # The single formatting site for every line of the report.
123
184
  #
124
185
  # `format`, never `printf`: printf writes straight to $stdout and returns
@@ -42,7 +42,10 @@ module Inquirex
42
42
 
43
43
  Workspace::LOCKSTEP.each_key do |name|
44
44
  problems = readiness_problems(name, target)
45
- out.puts format(" %<name>-16s %<state>s", name: name, state: problems.empty? ? "ready".bright_green : problems.join(", ").red)
45
+ # `.green`, not `.bright_green`: colored2 defines no such method, so
46
+ # the ready branch raised NoMethodError — and only the ready branch,
47
+ # which is why a preflight that found problems looked fine.
48
+ out.puts format(" %<name>-16s %<state>s", name: name, state: problems.empty? ? "ready".green : problems.join(", ").red)
46
49
  ok &&= problems.empty?
47
50
  end
48
51
 
@@ -5,6 +5,7 @@ require_relative "tools/workspace"
5
5
  require_relative "tools/version_checker"
6
6
  require_relative "tools/version_bumper"
7
7
  require_relative "tools/publisher"
8
+ require_relative "tools/releaser"
8
9
  require_relative "tools/changelogs"
9
10
 
10
11
  module Inquirex
@@ -24,6 +25,7 @@ module Inquirex
24
25
  # - `versions-check` — version parity across the lockstep packages
25
26
  # - `versions-bump` — move every lockstep package to one version
26
27
  # - `publish` — publish every package to RubyGems and npm
28
+ # - `create-release` — tag and create a GitHub release for every package
27
29
  # - `changelogs` — regenerate CHANGELOG.md from merged PRs
28
30
  # - `version` — this gem's own version
29
31
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inquirex-tools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.4
4
+ version: 0.9.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -63,6 +63,8 @@ files:
63
63
  - LICENSE.txt
64
64
  - README.md
65
65
  - Rakefile
66
+ - docs/badges/coverage_badge.svg
67
+ - docs/monorepo-migration.md
66
68
  - exe/inquirex
67
69
  - exe/inquirexor
68
70
  - justfile
@@ -70,11 +72,13 @@ files:
70
72
  - lib/inquirex/tools/changelogs.rb
71
73
  - lib/inquirex/tools/commands.rb
72
74
  - lib/inquirex/tools/commands/changelogs.rb
75
+ - lib/inquirex/tools/commands/create_release.rb
73
76
  - lib/inquirex/tools/commands/publish.rb
74
77
  - lib/inquirex/tools/commands/version.rb
75
78
  - lib/inquirex/tools/commands/versions_bump.rb
76
79
  - lib/inquirex/tools/commands/versions_check.rb
77
80
  - lib/inquirex/tools/publisher.rb
81
+ - lib/inquirex/tools/releaser.rb
78
82
  - lib/inquirex/tools/version.rb
79
83
  - lib/inquirex/tools/version_bumper.rb
80
84
  - lib/inquirex/tools/version_checker.rb