hecks 2.6.0 → 2.8.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.
Files changed (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. data/lib/hecks/query_ir.rb +0 -482
@@ -0,0 +1,112 @@
1
+ require_relative "gem_pin/published_versions"
2
+
3
+ module Hecks
4
+ module Release
5
+ # Finds the hecks version a project runs on, and refuses one that a
6
+ # stranger could not install.
7
+ #
8
+ # ## Why it exists
9
+ #
10
+ # A project that is copied out of its own workspace (handed to someone who
11
+ # will `bundle install` it, or built in an image with no sibling checkout)
12
+ # has to name a hecks release RubyGems actually publishes. A `path:` or
13
+ # `git:` source only works where the path or remote is reachable, and a
14
+ # version that was never published resolves nowhere. This reads the
15
+ # project's Gemfiles and lockfiles, without running Bundler, and says which
16
+ # version each pins or refuses.
17
+ #
18
+ # ## Resolution
19
+ #
20
+ # For each `Gemfile` under the root (a project can hold several, such as a
21
+ # domain and a helper app) the pin is, in order: the version its
22
+ # `Gemfile.lock` resolved, or the newest stable release its version
23
+ # constraint allows. Each pin must be published; the Gemfiles need not
24
+ # agree.
25
+ #
26
+ # ## Injectable network
27
+ #
28
+ # The published list comes from `published:`, any object answering
29
+ # `include?(version)` and `newest_satisfying(requirement)`. The default asks
30
+ # RubyGems; a caller without network hands in its own.
31
+ class GemPin
32
+ GEM_LINE = /^\s*gem\s+["']hecks["'](?<rest>.*)$/
33
+ SOURCE_OPTION = /\b(?:path|git|github|gitlab|bitbucket|branch|ref|tag):/
34
+ LOCKED = /^ hecks \((?<version>[^)]+)\)/
35
+
36
+ # What `resolve` found.
37
+ #
38
+ # @!attribute [r] version
39
+ # @return [String] the newest pin
40
+ # @!attribute [r] pins
41
+ # @return [Hash{String => String}] each Gemfile that declares hecks, relative to the
42
+ # root, mapped to the version it runs on
43
+ Resolved = Struct.new(:version, :pins, keyword_init: true)
44
+
45
+ # @param published [#include?, #newest_satisfying] the source of published versions
46
+ def initialize(published: PublishedVersions.new)
47
+ @published = published
48
+ end
49
+
50
+ # Resolves the hecks versions the project in `root` runs on.
51
+ #
52
+ # @param root [String] the project's root directory
53
+ # @return [Resolved] the newest pin and every pin
54
+ # @raise [Error] if no Gemfile declares hecks, one takes it from a source other than
55
+ # RubyGems, one pins a version RubyGems does not publish, or a constraint allows
56
+ # no published release
57
+ def resolve(root)
58
+ pins = gemfiles(root).filter_map { |gemfile| pin_for(gemfile, root) }.to_h
59
+ raise Error, "no Gemfile declares the hecks gem, so this is not a Hecks project" if pins.empty?
60
+
61
+ pins.each do |rel, version|
62
+ raise Error, "hecks #{version} is not published on RubyGems (#{rel})" unless @published.include?(version)
63
+ end
64
+ Resolved.new(version: pins.values.max_by { |version| Gem::Version.new(version) }, pins: pins)
65
+ end
66
+
67
+ private
68
+
69
+ def gemfiles(root)
70
+ Dir.glob("**/Gemfile", base: root).reject { |rel| rel.include?("node_modules/") }.sort
71
+ .map { |rel| File.join(root, rel) }
72
+ end
73
+
74
+ def pin_for(gemfile, root)
75
+ declaration = File.read(gemfile).match(GEM_LINE) or return nil
76
+ rel = gemfile.delete_prefix("#{root}/")
77
+ if (source = declaration[:rest][SOURCE_OPTION])
78
+ raise Error, "#{rel} takes hecks from `#{source}`, not from RubyGems; the project must use a published version"
79
+ end
80
+
81
+ [rel, locked_version(gemfile, rel) || newest_allowed(declaration[:rest], rel)]
82
+ end
83
+
84
+ # The version Gemfile.lock resolved, if there is a lockfile. A hecks
85
+ # entry under `PATH` or `GIT` means the lockfile is not installable either.
86
+ def locked_version(gemfile, rel)
87
+ lock = "#{gemfile}.lock"
88
+ return nil unless File.exist?(lock)
89
+
90
+ text = File.read(lock)
91
+ refuse_unpublished_source!(text, rel)
92
+ text.match(LOCKED)&.[](:version)
93
+ end
94
+
95
+ def refuse_unpublished_source!(lock_text, rel)
96
+ lock_text.split("\n\n").each do |section|
97
+ next unless section.start_with?("PATH", "GIT") && section.match?(LOCKED)
98
+
99
+ raise Error, "#{rel}.lock resolves hecks from a #{section.lines.first.strip} source, not from RubyGems"
100
+ end
101
+ end
102
+
103
+ def newest_allowed(rest, rel)
104
+ constraints = rest.scan(/["']([^"']+)["']/).flatten.grep(/\A\s*(?:[~><=!]+\s*)?\d/)
105
+ version = @published.newest_satisfying(Gem::Requirement.new(*constraints))
106
+ raise Error, "no published hecks release satisfies the constraint in #{rel}" unless version
107
+
108
+ version.to_s
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,96 @@
1
+ require_relative "commands"
2
+
3
+ module Hecks
4
+ module Release
5
+ class Runner
6
+ # The npm step when CI publishes: the release tag triggers
7
+ # `.github/workflows/publish-client.yml`, which publishes `@hecks/client`
8
+ # with npm trusted publishing, so nothing is published from this machine.
9
+ # This watches npm until the version appears.
10
+ #
11
+ # The clock and the pause are injected, so a spec runs the whole wait
12
+ # without sleeping.
13
+ class CiPublisher
14
+ WORKFLOW = ".github/workflows/publish-client.yml".freeze
15
+ INTERVAL = 15
16
+ LIMIT = 600
17
+
18
+ # @param root [String] the repository root
19
+ # @param published [Published] asks npm whether the version is out
20
+ # @param console [Console] progress output
21
+ # @param pause [#call] sleeps for a number of seconds
22
+ # @param now [#call] answers the current time in seconds, monotonically
23
+ def initialize(root:, published:, console:, pause:, now:)
24
+ @root = root
25
+ @published = published
26
+ @console = console
27
+ @pause = pause
28
+ @now = now
29
+ end
30
+
31
+ # Says whether the checkout carries the workflow that publishes the client.
32
+ #
33
+ # @return [Boolean] true when `.github/workflows/publish-client.yml` exists
34
+ def workflow?
35
+ File.exist?(File.join(@root, WORKFLOW))
36
+ end
37
+
38
+ # Tells the person CI publishes, then waits for the version to reach npm.
39
+ #
40
+ # @param version [String] the version being released
41
+ # @param dry_run [Boolean] say what would happen and wait for nothing
42
+ # @param wait [Boolean] false to report and return without polling
43
+ # @return [Boolean] false only when the wait timed out; true when the version appeared or
44
+ # nothing was waited for
45
+ def wait!(version, dry_run:, wait:)
46
+ @console.say("CI publishes @hecks/client #{version} from the tag (#{WORKFLOW}); nothing is published from here.")
47
+ return true.tap { preview } if dry_run
48
+ return true.tap { skip_wait(version) } unless wait
49
+
50
+ @console.say("Waiting for @hecks/client #{version} on npm (every #{INTERVAL}s, up to #{LIMIT / 60} minutes)...")
51
+ arrived = arrives?(version)
52
+ @console.say("@hecks/client #{version} is on npm.") if arrived
53
+ arrived
54
+ end
55
+
56
+ # The hint printed when the wait ends without the version on npm.
57
+ #
58
+ # @param version [String] the version being released
59
+ # @return [String] how to look at, re-run and fall back from the CI publish
60
+ def self.timeout_hint(version)
61
+ "Timed out after #{LIMIT / 60} minutes waiting for CI to publish @hecks/client #{version}. " \
62
+ "See the run: gh run list --workflow publish-client.yml. Re-run it: " \
63
+ "gh workflow run publish-client.yml -f tag=v#{version}. Then finish with: bin/release --npm-only. " \
64
+ "To publish from this machine instead: bin/release --npm-only --npm-local."
65
+ end
66
+
67
+ private
68
+
69
+ def preview
70
+ @console.say("Dry run: would wait up to #{LIMIT / 60} minutes for it to appear on npm.")
71
+ end
72
+
73
+ def skip_wait(version)
74
+ @console.say("Not waiting (--no-wait). Check the run: gh run list --workflow publish-client.yml; " \
75
+ "npm view @hecks/client@#{version} version")
76
+ end
77
+
78
+ def arrives?(version)
79
+ deadline = @now.call + LIMIT
80
+ loop do
81
+ return true if out?(version)
82
+ return false if @now.call >= deadline
83
+
84
+ @pause.call(INTERVAL)
85
+ end
86
+ end
87
+
88
+ def out?(version)
89
+ @published.npm?(version)
90
+ rescue Refusal
91
+ false
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,68 @@
1
+ require "open3"
2
+
3
+ module Hecks
4
+ module Release
5
+ class Runner
6
+ # Raised when a command a release step runs exits non-zero or cannot start.
7
+ class CommandFailed < StandardError; end
8
+
9
+ # Raised when a check refuses the release; the message names the fix.
10
+ class Refusal < StandardError; end
11
+
12
+ # Runs the release's external commands: git, gem, npm, curl, op.
13
+ #
14
+ # The runner takes any object answering `capture` and `run!` the way this
15
+ # one does, so a spec hands in a recorder and never starts a real process.
16
+ class Commands
17
+ # What a captured command printed and whether it succeeded.
18
+ #
19
+ # @!attribute [r] stdout
20
+ # @return [String] standard output
21
+ # @!attribute [r] stderr
22
+ # @return [String] standard error, or the reason the command could not start
23
+ # @!attribute [r] success
24
+ # @return [Boolean] true when the command exited zero
25
+ Result = Struct.new(:stdout, :stderr, :success, keyword_init: true) do
26
+ # Says whether the command exited zero.
27
+ #
28
+ # @return [Boolean] true when it did
29
+ def success?
30
+ success
31
+ end
32
+ end
33
+
34
+ # Runs a command and returns what it printed, whatever its exit status.
35
+ #
36
+ # @param argv [Array<String>] the command and its arguments, never a shell string
37
+ # @param env [Hash{String => String, nil}] variables to set, or unset with nil
38
+ # @param chdir [String, nil] the directory to run in; the current one when nil
39
+ # @return [Result] the output and status; a command that cannot start is a failed result
40
+ def capture(*argv, env: {}, chdir: nil)
41
+ stdout, stderr, status = Open3.capture3(env, *argv, **directory(chdir))
42
+ Result.new(stdout: stdout, stderr: stderr, success: status.success?)
43
+ rescue SystemCallError => e
44
+ Result.new(stdout: "", stderr: e.message, success: false)
45
+ end
46
+
47
+ # Runs a command with its output going straight to the terminal.
48
+ #
49
+ # @param argv [Array<String>] the command and its arguments, never a shell string
50
+ # @param env [Hash{String => String, nil}] variables to set, or unset with nil
51
+ # @param chdir [String, nil] the directory to run in; the current one when nil
52
+ # @return [true] when the command exits zero
53
+ # @raise [CommandFailed] if the command exits non-zero or cannot start
54
+ def run!(*argv, env: {}, chdir: nil)
55
+ return true if system(env, *argv, **directory(chdir))
56
+
57
+ raise CommandFailed, "`#{argv.first(2).join(' ')}` failed"
58
+ end
59
+
60
+ private
61
+
62
+ def directory(chdir)
63
+ chdir ? { chdir: chdir } : {}
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,52 @@
1
+ module Hecks
2
+ module Release
3
+ class Runner
4
+ # Talks to the person running the release: progress lines, warnings and the
5
+ # y/N questions that come before anything irreversible.
6
+ class Console
7
+ # @param input [IO] where answers are read from
8
+ # @param out [IO] where progress goes
9
+ # @param err [IO] where refusals and failures go
10
+ # @param assume_yes [Boolean] answer every question yes without asking
11
+ def initialize(input:, out:, err:, assume_yes: false)
12
+ @input = input
13
+ @out = out
14
+ @err = err
15
+ @assume_yes = assume_yes
16
+ end
17
+
18
+ # Prints a progress line.
19
+ #
20
+ # @param text [String] the line
21
+ # @return [void]
22
+ def say(text)
23
+ @out.puts(text)
24
+ end
25
+
26
+ # Prints a refusal or failure line.
27
+ #
28
+ # @param text [String] the line
29
+ # @return [void]
30
+ def warn(text)
31
+ @err.puts(text)
32
+ end
33
+
34
+ # Asks a yes/no question, defaulting to no.
35
+ #
36
+ # @param question [String] what will happen if the answer is yes
37
+ # @return [Boolean] true for `y` or `yes`, or always under `assume_yes`; false on anything
38
+ # else, including a closed input
39
+ def confirm?(question)
40
+ if @assume_yes
41
+ say("#{question} yes (--yes)")
42
+ return true
43
+ end
44
+
45
+ @out.print("#{question} [y/N] ")
46
+ @out.flush
47
+ %w[y yes].include?(@input.gets.to_s.strip.downcase)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,44 @@
1
+ require "fileutils"
2
+ require_relative "commands"
3
+
4
+ module Hecks
5
+ module Release
6
+ class Runner
7
+ # The gem step: hands the push to `bin/release_gem`, which owns building the
8
+ # gem and pushing it with the key 1Password holds. A dry run builds the gem
9
+ # to prove it builds and deletes it.
10
+ class GemPublisher
11
+ # @param root [String] the repository root
12
+ # @param commands [#run!] runs the build and the push
13
+ # @param console [Console] progress output
14
+ def initialize(root:, commands:, console:)
15
+ @root = root
16
+ @commands = commands
17
+ @console = console
18
+ end
19
+
20
+ # Pushes the gem, or with `dry_run` only builds it.
21
+ #
22
+ # @param version [String] the version being released
23
+ # @param dry_run [Boolean] build and delete the gem, pushing nothing
24
+ # @return [void]
25
+ # @raise [CommandFailed] if the build or the push fails
26
+ def publish!(version, dry_run:)
27
+ return build_only(version) if dry_run
28
+
29
+ @console.say("Publishing hecks #{version} to rubygems.org via bin/release_gem...")
30
+ @commands.run!(File.join(@root, "bin/release_gem"), chdir: @root)
31
+ end
32
+
33
+ private
34
+
35
+ def build_only(version)
36
+ @console.say("Dry run: building hecks-#{version}.gem (nothing is pushed)...")
37
+ @commands.run!("gem", "build", "hecks.gemspec", chdir: @root)
38
+ ensure
39
+ FileUtils.rm_f(File.join(@root, "hecks-#{version}.gem"))
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,48 @@
1
+ require "hecks/vendoring/git_environment"
2
+ require_relative "commands"
3
+
4
+ module Hecks
5
+ module Release
6
+ class Runner
7
+ # The release's view of the repository: git run in the checkout, with the
8
+ # repository-pinning environment variables a git hook exports unset.
9
+ class Git
10
+ # @param root [String] the repository root
11
+ # @param commands [#capture, #run!] runs the git commands
12
+ def initialize(root:, commands:)
13
+ @root = root
14
+ @commands = commands
15
+ end
16
+
17
+ # Runs a git command and returns what it printed.
18
+ #
19
+ # @param args [Array<String>] git's arguments
20
+ # @return [String] standard output
21
+ # @raise [Refusal] if git exits non-zero
22
+ def read(*args)
23
+ result = capture(*args)
24
+ raise Refusal, "git #{args.first} failed: #{result.stderr.strip}" unless result.success?
25
+
26
+ result.stdout
27
+ end
28
+
29
+ # Runs a git command and returns the result whatever its status.
30
+ #
31
+ # @param args [Array<String>] git's arguments
32
+ # @return [Commands::Result] output and status
33
+ def capture(*)
34
+ @commands.capture("git", *, env: Vendoring::GitEnvironment.clean, chdir: @root)
35
+ end
36
+
37
+ # Runs a git command that changes something, showing its output.
38
+ #
39
+ # @param args [Array<String>] git's arguments
40
+ # @return [true] when git exits zero
41
+ # @raise [CommandFailed] if git exits non-zero
42
+ def run!(*)
43
+ @commands.run!("git", *, env: Vendoring::GitEnvironment.clean, chdir: @root)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,89 @@
1
+ require "tempfile"
2
+ require_relative "commands"
3
+
4
+ module Hecks
5
+ module Release
6
+ class Runner
7
+ # The npm step: publishes `@hecks/client` from `packages/hecks-client`
8
+ # with the token 1Password holds.
9
+ #
10
+ # `op run` resolves `release/npm_publish.env` into the environment of the
11
+ # one `npm publish` process. npm reads its token from a config file, so a
12
+ # throwaway user config holds a placeholder that npm expands from that
13
+ # environment; the file never contains the token and is deleted afterwards.
14
+ # The package's `prepack` builds `dist/`.
15
+ #
16
+ # The token is a granular one that bypasses two-factor authentication, because
17
+ # the account's second factor is a passkey. As a fallback the publish runs with
18
+ # `--auth-type=web` and its output is not captured, so if npm does ask for an
19
+ # approval it prints the link straight to the terminal and waits for it.
20
+ class NpmPublisher
21
+ PACKAGE_DIR = "packages/hecks-client".freeze
22
+ ENV_FILE = "release/npm_publish.env".freeze
23
+ APPROVAL_NOTICE = "npm will print an approval link; open it and approve with your security key or passkey. " \
24
+ "This step waits for you.".freeze
25
+ USERCONFIG_LINE = "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}".freeze
26
+
27
+ # @param root [String] the repository root
28
+ # @param commands [#run!] runs npm and op
29
+ # @param console [Console] progress output
30
+ def initialize(root:, commands:, console:)
31
+ @root = root
32
+ @commands = commands
33
+ @console = console
34
+ end
35
+
36
+ # Publishes the package, or with `dry_run` only packs it.
37
+ #
38
+ # @param version [String] the version being released
39
+ # @param dry_run [Boolean] run `npm publish --dry-run`, which needs no credentials
40
+ # @return [void]
41
+ # @raise [CommandFailed] if installing, building or publishing fails
42
+ def publish!(version, dry_run:)
43
+ install_dependencies
44
+ if dry_run
45
+ @console.say("Dry run: npm publish --dry-run for @hecks/client #{version} (nothing is published)...")
46
+ @commands.run!("npm", "publish", "--dry-run", "--access", "public", chdir: package_dir)
47
+ else
48
+ @console.say("Publishing @hecks/client #{version} to npm (1Password will prompt for Touch ID)...")
49
+ @console.say(APPROVAL_NOTICE)
50
+ publish_with_token
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def package_dir
57
+ File.join(@root, PACKAGE_DIR)
58
+ end
59
+
60
+ def install_dependencies
61
+ return if Dir.exist?(File.join(package_dir, "node_modules"))
62
+
63
+ @console.say("Installing the client's dependencies (npm ci)...")
64
+ @commands.run!("npm", "ci", chdir: package_dir)
65
+ end
66
+
67
+ def publish_with_token
68
+ with_userconfig do |userconfig|
69
+ @commands.run!(
70
+ "op", "run", "--env-file=#{File.join(@root, ENV_FILE)}", "--",
71
+ "npm", "publish", "--access", "public", "--auth-type=web", "--userconfig", userconfig,
72
+ chdir: package_dir
73
+ )
74
+ end
75
+ end
76
+
77
+ def with_userconfig
78
+ file = Tempfile.new(["hecks-npmrc", ".npmrc"])
79
+ file.chmod(0o600)
80
+ file.write("#{USERCONFIG_LINE}\n")
81
+ file.flush
82
+ yield file.path
83
+ ensure
84
+ file&.close!
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,119 @@
1
+ require "json"
2
+ require_relative "commands"
3
+ require_relative "git"
4
+
5
+ module Hecks
6
+ module Release
7
+ class Runner
8
+ # The checks that run before a release does anything: the tools are
9
+ # installed, the checkout is a clean, current `main`, the gem and the
10
+ # JavaScript client agree on a version, and the changelog names it.
11
+ #
12
+ # Every refusal says what to do about it.
13
+ class Preflight
14
+ # What the checks learned about the release.
15
+ #
16
+ # @!attribute [r] version
17
+ # @return [String] the version `lib/hecks/version.rb` declares
18
+ # @!attribute [r] sha
19
+ # @return [String] the commit `HEAD` points at, which is also `origin/main`
20
+ Facts = Struct.new(:version, :sha, keyword_init: true)
21
+
22
+ INSTALL_HINTS = {
23
+ "git" => "install git",
24
+ "gem" => "install Ruby, which ships gem",
25
+ "npm" => "install Node.js: brew install node",
26
+ "curl" => "install curl",
27
+ "op" => "install the 1Password CLI: brew install 1password-cli"
28
+ }.freeze
29
+ VERSION_LINE = /^\s*VERSION\s*=\s*"(?<version>[^"]+)"/
30
+ CLIENT_PACKAGE = "packages/hecks-client/package.json".freeze
31
+
32
+ # @param root [String] the repository root
33
+ # @param commands [#capture] runs the tool probes
34
+ # @param git [Git] the repository the release is cut from
35
+ # @param tools [Array<String>] the executables the release needs on PATH
36
+ def initialize(root:, commands:, git:, tools:)
37
+ @root = root
38
+ @commands = commands
39
+ @git = git
40
+ @tools = tools
41
+ end
42
+
43
+ # Runs every check.
44
+ #
45
+ # @return [Facts] the version and commit the release is cut from
46
+ # @raise [Refusal] on the first check that fails, naming the fix
47
+ def check!
48
+ check_tools
49
+ check_branch
50
+ check_current
51
+ check_clean
52
+ version = declared_version
53
+ check_client_version(version)
54
+ check_changelog(version)
55
+ Facts.new(version: version, sha: git("rev-parse", "HEAD").strip)
56
+ end
57
+
58
+ private
59
+
60
+ def check_tools
61
+ @tools.each do |tool|
62
+ next if @commands.capture(tool, "--version").success?
63
+
64
+ raise Refusal, "#{tool} not found on PATH; #{INSTALL_HINTS.fetch(tool, "install #{tool}")}"
65
+ end
66
+ end
67
+
68
+ def check_branch
69
+ branch = git("rev-parse", "--abbrev-ref", "HEAD").strip
70
+ return if branch == "main"
71
+
72
+ raise Refusal, "on branch #{branch}, not main; release from main: git checkout main"
73
+ end
74
+
75
+ def check_current
76
+ fetch = @git.capture("fetch", "origin")
77
+ raise Refusal, "git fetch origin failed (#{fetch.stderr.strip}); check the network and the remote" unless fetch.success?
78
+
79
+ head = git("rev-parse", "HEAD").strip
80
+ upstream = git("rev-parse", "origin/main").strip
81
+ return if head == upstream
82
+
83
+ raise Refusal, "main (#{head[0, 7]}) is not origin/main (#{upstream[0, 7]}); " \
84
+ "merge the release PR, then git pull --ff-only"
85
+ end
86
+
87
+ def check_clean
88
+ return if git("status", "--porcelain").strip.empty?
89
+
90
+ raise Refusal, "the working tree has uncommitted changes; commit or discard them before releasing"
91
+ end
92
+
93
+ def declared_version
94
+ match = File.read(File.join(@root, "lib/hecks/version.rb")).match(VERSION_LINE)
95
+ raise Refusal, "lib/hecks/version.rb declares no VERSION; restore it" unless match
96
+
97
+ match[:version]
98
+ end
99
+
100
+ def check_client_version(version)
101
+ client = JSON.parse(File.read(File.join(@root, CLIENT_PACKAGE))).fetch("version")
102
+ return if client == version
103
+
104
+ raise Refusal, "packages/hecks-client is at #{client} but Hecks::VERSION is #{version}; bump the package first."
105
+ end
106
+
107
+ def check_changelog(version)
108
+ return if File.read(File.join(@root, "CHANGELOG.md")).match?(/^## \[#{Regexp.escape(version)}\]/)
109
+
110
+ raise Refusal, "CHANGELOG.md has no `## [#{version}]` heading; add the release entry in the release PR"
111
+ end
112
+
113
+ def git(*)
114
+ @git.read(*)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,55 @@
1
+ require "json"
2
+ require_relative "commands"
3
+
4
+ module Hecks
5
+ module Release
6
+ class Runner
7
+ # Asks the registries whether a version is already out, so a re-run of the
8
+ # release skips what an earlier run finished.
9
+ #
10
+ # Neither question needs credentials.
11
+ class Published
12
+ GEM_VERSIONS_URL = "https://rubygems.org/api/v1/versions/hecks.json".freeze
13
+ NPM_PACKAGE = "@hecks/client".freeze
14
+
15
+ # @param root [String] the repository root
16
+ # @param commands [#capture] runs curl and npm
17
+ def initialize(root:, commands:)
18
+ @root = root
19
+ @commands = commands
20
+ end
21
+
22
+ # Says whether RubyGems lists the hecks gem at a version.
23
+ #
24
+ # @param version [String] the version to look for
25
+ # @return [Boolean] true when it is published
26
+ # @raise [Refusal] if RubyGems cannot be reached or answers with something unreadable
27
+ def gem?(version)
28
+ result = @commands.capture("curl", "-fsS", GEM_VERSIONS_URL, chdir: @root)
29
+ unless result.success?
30
+ raise Refusal, "could not list published hecks versions (#{result.stderr.strip}); " \
31
+ "check the network and re-run"
32
+ end
33
+
34
+ JSON.parse(result.stdout).any? { |entry| entry["number"] == version }
35
+ rescue JSON::ParserError
36
+ raise Refusal, "RubyGems answered with something other than a version list; re-run in a minute"
37
+ end
38
+
39
+ # Says whether npm lists @hecks/client at a version.
40
+ #
41
+ # @param version [String] the version to look for
42
+ # @return [Boolean] true when it is published
43
+ # @raise [Refusal] if npm fails for any reason other than the version not existing
44
+ def npm?(version)
45
+ result = @commands.capture("npm", "view", "#{NPM_PACKAGE}@#{version}", "version", chdir: @root)
46
+ return result.stdout.strip == version if result.success?
47
+ return false if result.stderr.include?("E404")
48
+
49
+ raise Refusal, "could not check npm for #{NPM_PACKAGE}@#{version} (#{result.stderr.strip.lines.first}); " \
50
+ "check the network and re-run"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end