inquirex-tools 0.9.4 → 0.10.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 +4 -4
- data/.envrc +2 -0
- data/docs/badges/coverage_badge.svg +21 -0
- data/docs/plans/001-/342/232/252/357/270/217-many-repo-setup/spec.md +29 -0
- data/docs/plans/002-/342/232/252/357/270/217-monorepo-migration/spec.md +163 -0
- data/lib/inquirex/tools/changelogs.rb +26 -3
- data/lib/inquirex/tools/commands/create_release.rb +56 -0
- data/lib/inquirex/tools/commands/publish.rb +17 -3
- data/lib/inquirex/tools/commands.rb +4 -0
- data/lib/inquirex/tools/pin_updater.rb +89 -0
- data/lib/inquirex/tools/publisher.rb +88 -7
- data/lib/inquirex/tools/releaser.rb +165 -0
- data/lib/inquirex/tools/version.rb +1 -1
- data/lib/inquirex/tools/version_bumper.rb +81 -16
- data/lib/inquirex/tools/version_checker.rb +4 -1
- data/lib/inquirex/tools.rb +32 -0
- metadata +8 -3
- data/.DS_Store +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0382f5f2ef1fed61354873914a92e24e3b25dbead0959aac9de8f48702bdfdf
|
|
4
|
+
data.tar.gz: 25c18df8bb5cb9a7bcad98668431ff7587df5444b7797917f8c3201308168170
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0dc205efda474da503f17a2ac1259b12193996621bba9041ff696e67563bde5b050dd0ca52bb5149b261b694fdb941b29755dd05a9fb71a81c335c65ce903e8d
|
|
7
|
+
data.tar.gz: ddd1e89483f67689fc64b4d4618d19b0a667dfd91da9778b26ee56d7ff36c54c657540bf15441c7fb53273d15b4403d5d4705f1f61292e8772142fab65d6535c
|
data/.envrc
CHANGED
|
@@ -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,29 @@
|
|
|
1
|
+
# Many Repos Setup
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Inquirex Organization houses several interlinked public repositories under https://github.com/inquirex that are often worked on in tandem, and checked out into the same folder.
|
|
6
|
+
|
|
7
|
+
For instance, seeting up the entire tree requires the following:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd ~/
|
|
11
|
+
mkdir -p ~/github/inquirex
|
|
12
|
+
cd ~/github/inquirex
|
|
13
|
+
declare -a repos=(
|
|
14
|
+
inquirex
|
|
15
|
+
inquirex-llm
|
|
16
|
+
inquirex-tty
|
|
17
|
+
inquirex-webui
|
|
18
|
+
inquirex-widget
|
|
19
|
+
inquirex-tools
|
|
20
|
+
inquirex-claude
|
|
21
|
+
)
|
|
22
|
+
for repo in ${repos[@]}; do
|
|
23
|
+
git clone git@github.com:inquirex/${repo}
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
cd inquirex-tools && bin/setup
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
|
|
@@ -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,34 @@ 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
|
|
26
|
+
# output, with this gem's bundle scrubbed — see
|
|
27
|
+
# {Inquirex::Tools.unbundled}. The generator is a gem of its own and must
|
|
28
|
+
# resolve against the installation that provides it, not against ours.
|
|
29
|
+
DEFAULT_RUNNER = lambda { |dir, env|
|
|
30
|
+
Inquirex::Tools.unbundled do
|
|
31
|
+
system(env, "github_changelog_generator", chdir: dir, out: File::NULL, err: File::NULL)
|
|
32
|
+
end
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
# @param workspace [Workspace]
|
|
22
36
|
# @param out [IO]
|
|
23
|
-
|
|
37
|
+
# @param token_reader [#call] returns a GitHub token, or "" when there is
|
|
38
|
+
# no session. Injected so a spec never needs one.
|
|
39
|
+
# @param runner [#call] invoked with (dir, env) to run the generator.
|
|
40
|
+
# Injected so a spec never shells out to it.
|
|
41
|
+
def initialize(workspace: Workspace.new,
|
|
42
|
+
out: $stdout,
|
|
43
|
+
token_reader: DEFAULT_TOKEN_READER,
|
|
44
|
+
runner: DEFAULT_RUNNER)
|
|
24
45
|
@workspace = workspace
|
|
25
46
|
@out = out
|
|
47
|
+
@token_reader = token_reader
|
|
48
|
+
@runner = runner
|
|
26
49
|
end
|
|
27
50
|
|
|
28
51
|
# @return [Array<String>] every repo carrying a generator config
|
|
@@ -41,7 +64,7 @@ module Inquirex
|
|
|
41
64
|
# @param names [Array<String>] repositories, or all configured when empty
|
|
42
65
|
# @return [Boolean] true when every repository succeeded
|
|
43
66
|
def generate(*names)
|
|
44
|
-
token =
|
|
67
|
+
token = @token_reader.call.to_s.strip
|
|
45
68
|
if token.empty?
|
|
46
69
|
@out.puts "ERROR: `gh auth token` returned nothing — run `gh auth login`"
|
|
47
70
|
return false
|
|
@@ -69,7 +92,7 @@ module Inquirex
|
|
|
69
92
|
|
|
70
93
|
@out.print format(" %<name>-16s ", name: name)
|
|
71
94
|
env = { "CHANGELOG_GITHUB_TOKEN" => token, "RBENV_VERSION" => ENV.fetch("RBENV_VERSION", DEFAULT_RUBY) }
|
|
72
|
-
if
|
|
95
|
+
if @runner.call(dir, env)
|
|
73
96
|
@out.puts "ok (#{line_count(File.join(dir, "CHANGELOG.md"))} lines)"
|
|
74
97
|
true
|
|
75
98
|
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
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "workspace"
|
|
4
|
+
|
|
5
|
+
module Inquirex
|
|
6
|
+
module Tools
|
|
7
|
+
# Raises the "newest published gem" pin that a package's CI installs from
|
|
8
|
+
# rubygems.org.
|
|
9
|
+
#
|
|
10
|
+
# `inquirex-webui` reparses its printer's output with the real gem, which
|
|
11
|
+
# CI installs from the registry rather than from a checkout. That pin
|
|
12
|
+
# therefore cannot name the version in the source tree — it does not exist
|
|
13
|
+
# yet — so it trails by one release and has to be raised by hand afterwards.
|
|
14
|
+
#
|
|
15
|
+
# Nobody remembers. The pin sat at 0.9.4 while the printer started emitting
|
|
16
|
+
# `min`, `max` and `step_size`, added to the gem in 0.9.5, and the
|
|
17
|
+
# round-trip failed with "`min` is not allowed inside a step block":
|
|
18
|
+
# SafeSource is default-deny, so a step setter it has never heard of is
|
|
19
|
+
# rejected outright. The failure names the printer, not the pin.
|
|
20
|
+
#
|
|
21
|
+
# Running immediately after a successful publish is what makes this safe.
|
|
22
|
+
# At that moment the version is on the registry by construction, so the
|
|
23
|
+
# pin's one rule — never run ahead of rubygems.org — cannot be violated.
|
|
24
|
+
#
|
|
25
|
+
# @example Raise the pin to the version just published
|
|
26
|
+
# Inquirex::Tools::PinUpdater.new.call("0.9.6") # => ["inquirex-webui/.github/workflows/ci.yml"]
|
|
27
|
+
class PinUpdater
|
|
28
|
+
# A pinned family version in a workflow's `env:` block. Anchored to the
|
|
29
|
+
# whole line so a value mentioned in a comment is left alone.
|
|
30
|
+
PIN = /^(?<lead>\s*INQUIREX_GEM_VERSION:\s*)(?<quote>["']?)\d+\.\d+\.\d+\k<quote>\s*$/
|
|
31
|
+
|
|
32
|
+
# @param workspace [Workspace] the ecosystem checkout to rewrite
|
|
33
|
+
# @param out [IO] where report output goes
|
|
34
|
+
def initialize(workspace: Workspace.new, out: $stdout)
|
|
35
|
+
@workspace = workspace
|
|
36
|
+
@out = out
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Rewrites every pin to `version`.
|
|
40
|
+
#
|
|
41
|
+
# @param version [String] the version that was just published
|
|
42
|
+
# @param dry_run [Boolean] report what would change and write nothing
|
|
43
|
+
# @return [Array<String>] workspace-relative paths that changed
|
|
44
|
+
def call(version, dry_run: false)
|
|
45
|
+
changed = workflows.select { |path| rewrite(path, version, dry_run:) }
|
|
46
|
+
report(changed, version, dry_run:)
|
|
47
|
+
changed.map { |path| path.delete_prefix("#{workspace.root}/") }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# @return [Workspace] the ecosystem checkout being rewritten
|
|
53
|
+
attr_reader :workspace
|
|
54
|
+
|
|
55
|
+
# @return [IO] where report output goes
|
|
56
|
+
attr_reader :out
|
|
57
|
+
|
|
58
|
+
# @return [Array<String>] every workflow file in every lockstep package
|
|
59
|
+
def workflows
|
|
60
|
+
Workspace::LOCKSTEP.keys.flat_map do |name|
|
|
61
|
+
Dir.glob(File.join(workspace.root, name, ".github", "workflows", "*.y{a,}ml"))
|
|
62
|
+
end.sort
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] whether the file needed rewriting
|
|
66
|
+
def rewrite(path, version, dry_run:)
|
|
67
|
+
source = File.read(path)
|
|
68
|
+
updated = source.gsub(PIN) { "#{::Regexp.last_match(:lead)}\"#{version}\"" }
|
|
69
|
+
return false if updated == source
|
|
70
|
+
|
|
71
|
+
File.write(path, updated) unless dry_run
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [void]
|
|
76
|
+
def report(changed, version, dry_run:)
|
|
77
|
+
return if changed.empty?
|
|
78
|
+
|
|
79
|
+
verb = dry_run ? "Would raise" : "Raised"
|
|
80
|
+
out.puts
|
|
81
|
+
out.puts "#{verb} the published-gem pin to #{version}:"
|
|
82
|
+
changed.each { |path| out.puts " #{path.delete_prefix("#{workspace.root}/")}" }
|
|
83
|
+
# The publisher does not commit — it has no business deciding what a
|
|
84
|
+
# release commit looks like — so say plainly that this is not done yet.
|
|
85
|
+
out.puts "Commit that; CI installs the pinned version from the registry.".dark unless dry_run
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
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
|
|
@@ -38,9 +40,11 @@ module Inquirex
|
|
|
38
40
|
ROTATE_INTERVAL = 2
|
|
39
41
|
|
|
40
42
|
# Shells out with the child's stdout and stderr left attached, so a
|
|
41
|
-
# failing package's own output is what the operator sees
|
|
43
|
+
# failing package's own output is what the operator sees, and with this
|
|
44
|
+
# gem's bundle scrubbed so the recipe resolves its own — see
|
|
45
|
+
# {Inquirex::Tools.unbundled}.
|
|
42
46
|
DEFAULT_RUNNER = lambda { |dir, argv|
|
|
43
|
-
Dir.chdir(dir) { system("just", *argv) }
|
|
47
|
+
Inquirex::Tools.unbundled { Dir.chdir(dir) { system("just", *argv) } }
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
# Reads the current RubyGems TOTP. `op` resolves the account from
|
|
@@ -53,39 +57,92 @@ module Inquirex
|
|
|
53
57
|
code.empty? ? nil : code
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
# Asks the registry whether a version is already public.
|
|
61
|
+
#
|
|
62
|
+
# Returns nil rather than false when the answer cannot be obtained — no
|
|
63
|
+
# network, a 500, a timeout. The caller treats nil as "attempt it": the
|
|
64
|
+
# registry itself refuses a duplicate, so guessing "not published" and
|
|
65
|
+
# trying is safe, whereas guessing "published" would silently skip a
|
|
66
|
+
# package that never shipped.
|
|
67
|
+
DEFAULT_PUBLISHED_CHECKER = lambda { |name, kind, version|
|
|
68
|
+
url = case kind
|
|
69
|
+
when :gem then "https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json"
|
|
70
|
+
when :npm then "https://registry.npmjs.org/#{name}/#{version}"
|
|
71
|
+
end
|
|
72
|
+
begin
|
|
73
|
+
uri = URI.parse(url)
|
|
74
|
+
response = Net::HTTP.start(uri.host,
|
|
75
|
+
uri.port,
|
|
76
|
+
use_ssl: true,
|
|
77
|
+
open_timeout: 5,
|
|
78
|
+
read_timeout: 5) do |http|
|
|
79
|
+
http.head(uri.request_uri)
|
|
80
|
+
end
|
|
81
|
+
case response
|
|
82
|
+
when Net::HTTPSuccess then true
|
|
83
|
+
when Net::HTTPNotFound then false
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
# @param workspace [Workspace] the ecosystem checkout to publish from
|
|
57
91
|
# @param out [IO] where report output goes
|
|
58
92
|
# @param runner [#call] invoked with (dir, argv) to run a command; the
|
|
59
93
|
# default shells out. Injected so specs can assert what would run
|
|
60
94
|
# without publishing anything.
|
|
61
95
|
# @param otp_reader [#call] returns the current 2FA code, or nil
|
|
96
|
+
# @param published_checker [#call] (name, kind, version) => true/false/nil
|
|
97
|
+
# @param pin_updater [#call] (version, dry_run:) raises the published-gem
|
|
98
|
+
# pin once the version is certainly on the registry
|
|
62
99
|
def initialize(workspace: Workspace.new,
|
|
63
100
|
out: $stdout,
|
|
64
101
|
runner: DEFAULT_RUNNER,
|
|
65
|
-
otp_reader: DEFAULT_OTP_READER
|
|
102
|
+
otp_reader: DEFAULT_OTP_READER,
|
|
103
|
+
published_checker: DEFAULT_PUBLISHED_CHECKER,
|
|
104
|
+
pin_updater: nil)
|
|
66
105
|
@workspace = workspace
|
|
67
106
|
@out = out
|
|
68
107
|
@runner = runner
|
|
69
108
|
@otp_reader = otp_reader
|
|
109
|
+
@published_checker = published_checker
|
|
110
|
+
@pin_updater = pin_updater || PinUpdater.new(workspace:, out:)
|
|
70
111
|
end
|
|
71
112
|
|
|
72
113
|
# Publishes every lockstep package, or prints what it would publish.
|
|
73
114
|
#
|
|
115
|
+
# Idempotent: a package whose current version is already on its registry
|
|
116
|
+
# is skipped, so re-running after a mid-release failure picks up exactly
|
|
117
|
+
# where it stopped instead of throwing six rejections at two registries.
|
|
118
|
+
#
|
|
74
119
|
# @param dry_run [Boolean] print the commands and run nothing
|
|
75
120
|
# @param args [Array<String>] extra arguments forwarded verbatim to each
|
|
76
121
|
# package's `just publish`
|
|
77
|
-
# @
|
|
78
|
-
|
|
122
|
+
# @param force [Boolean] attempt every package even when it looks published
|
|
123
|
+
# @return [Boolean] true when every package published, skipped or printed
|
|
124
|
+
def call(dry_run: false, args: [], force: false)
|
|
79
125
|
out.puts(dry_run ? "DRY RUN — these commands would run, in this order:" : "Publishing the family:")
|
|
80
126
|
|
|
81
127
|
last_otp = nil
|
|
128
|
+
published = 0
|
|
129
|
+
skipped = 0
|
|
130
|
+
|
|
82
131
|
Workspace::LOCKSTEP.each do |name, kind|
|
|
83
132
|
dir = File.join(workspace.root, name)
|
|
84
133
|
raise Error, "no checkout for #{name} at #{dir}" unless Dir.exist?(dir)
|
|
85
134
|
|
|
135
|
+
version = workspace.version_of(name)
|
|
136
|
+
if !force && version && published_checker.call(name, kind, version)
|
|
137
|
+
row(name, kind, "already #{version} on #{registry(kind)} — skipping".dark)
|
|
138
|
+
skipped += 1
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
|
|
86
142
|
argv = ["publish", *args]
|
|
87
143
|
if dry_run
|
|
88
|
-
row(name, kind, "just #{argv.join(" ")}")
|
|
144
|
+
row(name, kind, "just #{argv.join(" ")}#{" (#{version})" if version}")
|
|
145
|
+
published += 1
|
|
89
146
|
next
|
|
90
147
|
end
|
|
91
148
|
|
|
@@ -98,10 +155,15 @@ module Inquirex
|
|
|
98
155
|
out.puts
|
|
99
156
|
out.puts "━━ #{name} (#{kind}) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
100
157
|
return false unless run(name, dir, argv)
|
|
158
|
+
|
|
159
|
+
published += 1
|
|
101
160
|
end
|
|
102
161
|
|
|
103
162
|
out.puts
|
|
104
|
-
out.puts(dry_run
|
|
163
|
+
out.puts summary(dry_run:, published:, skipped:)
|
|
164
|
+
# Only now is the version certainly on the registry, which is the one
|
|
165
|
+
# rule the pin has to obey.
|
|
166
|
+
pin_updater.call(workspace.version_of("inquirex"), dry_run:)
|
|
105
167
|
true
|
|
106
168
|
end
|
|
107
169
|
|
|
@@ -119,6 +181,12 @@ module Inquirex
|
|
|
119
181
|
# @return [#call] source of 2FA codes
|
|
120
182
|
attr_reader :otp_reader
|
|
121
183
|
|
|
184
|
+
# @return [#call] asks a registry whether a version is already public
|
|
185
|
+
attr_reader :published_checker
|
|
186
|
+
|
|
187
|
+
# @return [#call] raises the published-gem pin after a release
|
|
188
|
+
attr_reader :pin_updater
|
|
189
|
+
|
|
122
190
|
# @return [Boolean] whether the package published cleanly
|
|
123
191
|
def run(name, dir, argv)
|
|
124
192
|
return true if runner.call(dir, argv)
|
|
@@ -155,6 +223,19 @@ module Inquirex
|
|
|
155
223
|
def row(name, kind, detail)
|
|
156
224
|
out.puts " #{name.ljust(18).yellow} #{kind.to_s.ljust(4).cyan} #{detail}"
|
|
157
225
|
end
|
|
226
|
+
|
|
227
|
+
# @return [String] human-readable registry name for a package kind
|
|
228
|
+
def registry(kind) = kind == :gem ? "RubyGems" : "npm"
|
|
229
|
+
|
|
230
|
+
# @return [String] the closing line, which has to distinguish "nothing to
|
|
231
|
+
# do because everything is already out" from "nothing happened"
|
|
232
|
+
def summary(dry_run:, published:, skipped:)
|
|
233
|
+
return "Everything is already published — nothing to do.".green if published.zero? && skipped.positive?
|
|
234
|
+
|
|
235
|
+
counted = "#{published} package#{"s" unless published == 1}"
|
|
236
|
+
note = skipped.positive? ? ", #{skipped} already published" : ""
|
|
237
|
+
dry_run ? "Would publish #{counted}#{note}. Nothing was published." : "Published #{counted}#{note}.".green
|
|
238
|
+
end
|
|
158
239
|
end
|
|
159
240
|
end
|
|
160
241
|
end
|
|
@@ -0,0 +1,165 @@
|
|
|
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, and with
|
|
38
|
+
# this gem's bundle scrubbed so the recipe resolves its own — see
|
|
39
|
+
# {Inquirex::Tools.unbundled}.
|
|
40
|
+
DEFAULT_RUNNER = lambda { |dir, argv|
|
|
41
|
+
Inquirex::Tools.unbundled { Dir.chdir(dir) { system("just", *argv) } }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# @param workspace [Workspace] the ecosystem checkout to release from
|
|
45
|
+
# @param out [IO] where report output goes
|
|
46
|
+
# @param runner [#call] invoked with (dir, argv) to run a command; the
|
|
47
|
+
# default shells out. Injected so specs can assert what would run
|
|
48
|
+
# without creating a tag or a GitHub release.
|
|
49
|
+
def initialize(workspace: Workspace.new, out: $stdout, runner: DEFAULT_RUNNER)
|
|
50
|
+
@workspace = workspace
|
|
51
|
+
@out = out
|
|
52
|
+
@runner = runner
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Creates a GitHub release for every lockstep package.
|
|
56
|
+
#
|
|
57
|
+
# Stops at the first package that fails or is refused, and says so. The
|
|
58
|
+
# packages after it are left alone rather than half-tagged.
|
|
59
|
+
#
|
|
60
|
+
# @param dry_run [Boolean] print what would run; tag nothing
|
|
61
|
+
# @param force [Boolean] release despite a dirty tree or a non-main branch
|
|
62
|
+
# @param args [Array<String>] extra arguments forwarded to `just release`
|
|
63
|
+
# @return [Boolean] true when every package released or was printed
|
|
64
|
+
def call(dry_run: false, force: false, args: [])
|
|
65
|
+
out.puts(dry_run ? "DRY RUN — these releases would be created:" : "Creating GitHub releases for the family:")
|
|
66
|
+
|
|
67
|
+
released = 0
|
|
68
|
+
|
|
69
|
+
Workspace::LOCKSTEP.each_key do |name|
|
|
70
|
+
dir = File.join(workspace.root, name)
|
|
71
|
+
raise Error, "no checkout for #{name} at #{dir}" unless Dir.exist?(dir)
|
|
72
|
+
|
|
73
|
+
version = workspace.version_of(name)
|
|
74
|
+
|
|
75
|
+
blocker = force ? nil : blocker_for(name)
|
|
76
|
+
if blocker
|
|
77
|
+
row(name, version, blocker.red)
|
|
78
|
+
return refused(name)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
argv = ["release", *args]
|
|
82
|
+
if dry_run
|
|
83
|
+
row(name, version, "just #{argv.join(" ")}")
|
|
84
|
+
released += 1
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
out.puts
|
|
89
|
+
out.puts "━━ #{name} v#{version} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
90
|
+
return false unless run(name, dir, argv)
|
|
91
|
+
|
|
92
|
+
released += 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
out.puts
|
|
96
|
+
out.puts summary(dry_run:, released:)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# @return [Workspace] the ecosystem checkout being released
|
|
103
|
+
attr_reader :workspace
|
|
104
|
+
|
|
105
|
+
# @return [IO] where report output goes
|
|
106
|
+
attr_reader :out
|
|
107
|
+
|
|
108
|
+
# @return [#call] command runner
|
|
109
|
+
attr_reader :runner
|
|
110
|
+
|
|
111
|
+
# Why this package must not be released, or nil when it is fine.
|
|
112
|
+
#
|
|
113
|
+
# A `clean` of nil means the directory is not a git checkout at all. That
|
|
114
|
+
# is left for the recipe to fail on rather than pre-judged here: a state
|
|
115
|
+
# that cannot be read should never silently skip a package, because
|
|
116
|
+
# "unknown" and "not releasable" are different answers.
|
|
117
|
+
#
|
|
118
|
+
# @param name [String] package name
|
|
119
|
+
# @return [String, nil] human-readable blocker
|
|
120
|
+
def blocker_for(name)
|
|
121
|
+
state = workspace.git_state(name)
|
|
122
|
+
return "working tree is dirty — the tag would not match what was tested" if state[:clean] == false
|
|
123
|
+
|
|
124
|
+
branch = state[:branch]
|
|
125
|
+
return nil if branch.nil? || branch.empty? || branch == RELEASE_BRANCH
|
|
126
|
+
|
|
127
|
+
"on #{branch}, not #{RELEASE_BRANCH} — the tag would point at unmerged work"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param name [String] the package that was refused
|
|
131
|
+
# @return [Boolean] always false, so the caller can `return refused(name)`
|
|
132
|
+
def refused(name)
|
|
133
|
+
out.puts
|
|
134
|
+
out.puts "#{"ERROR".bold.red}: #{name} is not releasable — stopping here."
|
|
135
|
+
out.puts "Nothing was tagged. Fix the checkout, or re-run with --force to override."
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [Boolean] whether the package released cleanly
|
|
140
|
+
def run(name, dir, argv)
|
|
141
|
+
return true if runner.call(dir, argv)
|
|
142
|
+
|
|
143
|
+
out.puts
|
|
144
|
+
out.puts "#{"ERROR".bold.red}: #{name} failed `just #{argv.first}` — stopping here."
|
|
145
|
+
out.puts "A release is re-runnable, so fix the cause and issue the command again."
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Pads before colorizing: an ANSI escape is several characters wide to
|
|
150
|
+
# `String#ljust` and zero columns wide on screen, so colorizing first left
|
|
151
|
+
# the table ragged by exactly the length of the escape codes.
|
|
152
|
+
#
|
|
153
|
+
# @return [void]
|
|
154
|
+
def row(name, version, detail)
|
|
155
|
+
out.puts " #{name.ljust(18).yellow} #{(version || "?").ljust(8).cyan} #{detail}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [String] the closing line
|
|
159
|
+
def summary(dry_run:, released:)
|
|
160
|
+
counted = "#{released} release#{"s" unless released == 1}"
|
|
161
|
+
dry_run ? "Would create #{counted}. Nothing was tagged." : "Created #{counted}.".green
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
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,47 @@ 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, with this gem's
|
|
64
|
+
# bundle scrubbed so those checks resolve the package's own Gemfile
|
|
65
|
+
# rather than inheriting ours — see {Inquirex::Tools.unbundled}.
|
|
66
|
+
DEFAULT_RUNNER = lambda { |dir, argv|
|
|
67
|
+
Inquirex::Tools.unbundled { Dir.chdir(dir) { system(*argv) } }
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
# @param workspace [Workspace] the ecosystem checkout to rewrite
|
|
41
71
|
# @param out [IO] where report output goes
|
|
42
|
-
|
|
72
|
+
# @param git [#call] invoked with (dir, argv); returns [ok, output].
|
|
73
|
+
# Injected so specs can assert what would run without touching a repo.
|
|
74
|
+
# @param runner [#call] invoked with (dir, argv) to run a package's
|
|
75
|
+
# checks. Injected for the same reason: a unit test must not shell out
|
|
76
|
+
# to six real suites.
|
|
77
|
+
def initialize(workspace: Workspace.new, out: $stdout, git: DEFAULT_GIT, runner: DEFAULT_RUNNER)
|
|
43
78
|
@workspace = workspace
|
|
44
79
|
@out = out
|
|
80
|
+
@git = git
|
|
81
|
+
@runner = runner
|
|
45
82
|
end
|
|
46
83
|
|
|
47
84
|
# Rewrites every lockstep package to the target version, then runs each
|
|
@@ -74,6 +111,12 @@ module Inquirex
|
|
|
74
111
|
# @return [IO] where report output goes
|
|
75
112
|
attr_reader :out
|
|
76
113
|
|
|
114
|
+
# @return [#call] runs one git command, returning [ok, output]
|
|
115
|
+
attr_reader :git
|
|
116
|
+
|
|
117
|
+
# @return [#call] runs a package's checks, returning truthy on success
|
|
118
|
+
attr_reader :runner
|
|
119
|
+
|
|
77
120
|
# The one place the package list is walked, so adding a package changes
|
|
78
121
|
# nothing here.
|
|
79
122
|
#
|
|
@@ -100,25 +143,47 @@ module Inquirex
|
|
|
100
143
|
# @return [Boolean, nil] pass/fail, or nil when skipped
|
|
101
144
|
def check(name)
|
|
102
145
|
dir = File.join(workspace.root, name)
|
|
103
|
-
|
|
104
|
-
row(name, "
|
|
105
|
-
|
|
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)
|
|
146
|
+
unless File.exist?(File.join(dir, "justfile"))
|
|
147
|
+
row(name, "skip".yellow, "no justfile".yellow)
|
|
148
|
+
return nil
|
|
117
149
|
end
|
|
118
|
-
|
|
150
|
+
|
|
151
|
+
sync(name, dir)
|
|
152
|
+
passed = runner.call(dir, CHECK)
|
|
153
|
+
row(name, passed ? "ok".green : "FAIL".red, dir)
|
|
119
154
|
passed
|
|
120
155
|
end
|
|
121
156
|
|
|
157
|
+
# Brings one package level with origin/main before its suite runs.
|
|
158
|
+
#
|
|
159
|
+
# Two states are reported and left alone rather than acted on:
|
|
160
|
+
#
|
|
161
|
+
# - **not a git checkout** — nothing to sync, and running git here is what
|
|
162
|
+
# printed "fatal: not a git repository" once per command per package.
|
|
163
|
+
# - **uncommitted changes** — the tree is *not* stashed. A version bump
|
|
164
|
+
# that silently runs `git stash -u` can bury work the operator never
|
|
165
|
+
# agreed to move, and the stash stack is shared across worktrees, so it
|
|
166
|
+
# is not even reliably theirs to pop back. Rebasing on top of local
|
|
167
|
+
# edits is equally unwelcome. Skipping is the only non-destructive
|
|
168
|
+
# answer; the checks still run against what is on disk.
|
|
169
|
+
#
|
|
170
|
+
# @param name [String] package name
|
|
171
|
+
# @param dir [String] absolute path to the package
|
|
172
|
+
# @return [void]
|
|
173
|
+
def sync(name, dir)
|
|
174
|
+
state = workspace.git_state(name)
|
|
175
|
+
return row(name, "sync".yellow, "not a git checkout".yellow) if state[:clean].nil?
|
|
176
|
+
return row(name, "sync".yellow, "uncommitted changes — left alone".yellow) unless state[:clean]
|
|
177
|
+
|
|
178
|
+
SYNC_STEPS.each do |argv|
|
|
179
|
+
ok, output = git.call(dir, argv)
|
|
180
|
+
next if ok
|
|
181
|
+
|
|
182
|
+
row(name, "sync".red, "git #{argv.first} failed: #{output.to_s.lines.first&.strip}".red)
|
|
183
|
+
break
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
122
187
|
# The single formatting site for every line of the report.
|
|
123
188
|
#
|
|
124
189
|
# `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
|
-
|
|
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
|
|
data/lib/inquirex/tools.rb
CHANGED
|
@@ -4,7 +4,9 @@ require_relative "tools/version"
|
|
|
4
4
|
require_relative "tools/workspace"
|
|
5
5
|
require_relative "tools/version_checker"
|
|
6
6
|
require_relative "tools/version_bumper"
|
|
7
|
+
require_relative "tools/pin_updater"
|
|
7
8
|
require_relative "tools/publisher"
|
|
9
|
+
require_relative "tools/releaser"
|
|
8
10
|
require_relative "tools/changelogs"
|
|
9
11
|
|
|
10
12
|
module Inquirex
|
|
@@ -24,6 +26,7 @@ module Inquirex
|
|
|
24
26
|
# - `versions-check` — version parity across the lockstep packages
|
|
25
27
|
# - `versions-bump` — move every lockstep package to one version
|
|
26
28
|
# - `publish` — publish every package to RubyGems and npm
|
|
29
|
+
# - `create-release` — tag and create a GitHub release for every package
|
|
27
30
|
# - `changelogs` — regenerate CHANGELOG.md from merged PRs
|
|
28
31
|
# - `version` — this gem's own version
|
|
29
32
|
#
|
|
@@ -31,5 +34,34 @@ module Inquirex
|
|
|
31
34
|
# Inquirex::Tools::VersionChecker.new.preflight # => true when every package is ready
|
|
32
35
|
module Tools
|
|
33
36
|
class Error < StandardError; end
|
|
37
|
+
|
|
38
|
+
# Runs a block with Bundler's environment removed.
|
|
39
|
+
#
|
|
40
|
+
# Every orchestrator here shells into a *different* package and runs
|
|
41
|
+
# `bundle exec` there. `bundle exec inquirex versions-bump` exports
|
|
42
|
+
# BUNDLE_GEMFILE, RUBYOPT and friends, and a child process inherits them —
|
|
43
|
+
# so without this, each sibling resolves against **this gem's** Gemfile
|
|
44
|
+
# instead of its own.
|
|
45
|
+
#
|
|
46
|
+
# That failure is loud but misleading. `versions-bump` reported five of six
|
|
47
|
+
# packages failing `just check-all`: `inquirex` and `inquirex-llm` raised
|
|
48
|
+
# `NoMethodError: undefined method 'skip' for module SimpleCov`, because
|
|
49
|
+
# this gem pins simplecov ~> 0.22 while theirs resolve 1.0.3 where
|
|
50
|
+
# `SimpleCov.skip` exists; `inquirex-tty` raised `LoadError: cannot load
|
|
51
|
+
# such file -- inquirex`, because this gem deliberately does not depend on
|
|
52
|
+
# the gems it manages. Every one of those reads as a defect in the package
|
|
53
|
+
# being checked. None of them was.
|
|
54
|
+
#
|
|
55
|
+
# Bundler is not declared as a dependency — it is present whenever this
|
|
56
|
+
# runs under `bundle exec`, and when it is absent there is no environment
|
|
57
|
+
# to scrub, so the block simply runs.
|
|
58
|
+
#
|
|
59
|
+
# @yield with a clean environment
|
|
60
|
+
# @return [Object] whatever the block returns
|
|
61
|
+
def self.unbundled(&)
|
|
62
|
+
return yield unless defined?(::Bundler) && ::Bundler.respond_to?(:with_unbundled_env)
|
|
63
|
+
|
|
64
|
+
::Bundler.with_unbundled_env(&)
|
|
65
|
+
end
|
|
34
66
|
end
|
|
35
67
|
end
|
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.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
@@ -50,7 +50,6 @@ executables:
|
|
|
50
50
|
extensions: []
|
|
51
51
|
extra_rdoc_files: []
|
|
52
52
|
files:
|
|
53
|
-
- ".DS_Store"
|
|
54
53
|
- ".env.encrypted"
|
|
55
54
|
- ".env.example"
|
|
56
55
|
- ".envrc"
|
|
@@ -63,6 +62,9 @@ files:
|
|
|
63
62
|
- LICENSE.txt
|
|
64
63
|
- README.md
|
|
65
64
|
- Rakefile
|
|
65
|
+
- docs/badges/coverage_badge.svg
|
|
66
|
+
- docs/plans/001-⚪️-many-repo-setup/spec.md
|
|
67
|
+
- docs/plans/002-⚪️-monorepo-migration/spec.md
|
|
66
68
|
- exe/inquirex
|
|
67
69
|
- exe/inquirexor
|
|
68
70
|
- justfile
|
|
@@ -70,11 +72,14 @@ 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
|
|
80
|
+
- lib/inquirex/tools/pin_updater.rb
|
|
77
81
|
- lib/inquirex/tools/publisher.rb
|
|
82
|
+
- lib/inquirex/tools/releaser.rb
|
|
78
83
|
- lib/inquirex/tools/version.rb
|
|
79
84
|
- lib/inquirex/tools/version_bumper.rb
|
|
80
85
|
- lib/inquirex/tools/version_checker.rb
|
|
@@ -102,7 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
102
107
|
- !ruby/object:Gem::Version
|
|
103
108
|
version: '0'
|
|
104
109
|
requirements: []
|
|
105
|
-
rubygems_version: 4.0.
|
|
110
|
+
rubygems_version: 4.0.18
|
|
106
111
|
specification_version: 4
|
|
107
112
|
summary: Release tooling for the Inquirex package family
|
|
108
113
|
test_files: []
|
data/.DS_Store
DELETED
|
Binary file
|