hecks 2.7.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/memory.rb +1 -1
  4. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  5. data/lib/hecks/adapters/driven/prism.rb +1 -1
  6. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  7. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  8. data/lib/hecks/behaviors/expectations.rb +2 -2
  9. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  10. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  11. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  12. data/lib/hecks/bluebook/assembly.rb +1 -1
  13. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  14. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  15. data/lib/hecks/bluebook/capabilities.rb +2 -2
  16. data/lib/hecks/bluebook/chapter.rb +1 -1
  17. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  18. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  19. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  21. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  22. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  23. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
  26. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  27. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  28. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  29. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  30. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  31. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  32. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  33. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  34. data/lib/hecks/bluebook/model_check.rb +12 -13
  35. data/lib/hecks/cache_dir.rb +103 -0
  36. data/lib/hecks/cli/document.rb +104 -0
  37. data/lib/hecks/cli/ir.rb +47 -0
  38. data/lib/hecks/cli/mcp.rb +32 -0
  39. data/lib/hecks/cli/mcp_door.rb +461 -0
  40. data/lib/hecks/cli/model_check.rb +270 -0
  41. data/lib/hecks/cli/project_cli.rb +123 -0
  42. data/lib/hecks/cli/project_diagrams.rb +43 -0
  43. data/lib/hecks/cli/run.rb +219 -0
  44. data/lib/hecks/cli/smoke_test.rb +59 -0
  45. data/lib/hecks/cli/stores.rb +54 -0
  46. data/lib/hecks/cli.rb +166 -0
  47. data/lib/hecks/facade/handle.rb +2 -2
  48. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  50. data/lib/hecks/mcp_door_scope.rb +176 -0
  51. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  52. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
  53. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  54. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  55. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  56. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  57. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  58. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
  59. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  60. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  61. data/lib/hecks/projections/deploy/lambda.rb +3 -3
  62. data/lib/hecks/projections/reference.rb +7 -1
  63. data/lib/hecks/projector/exporter.rb +4 -4
  64. data/lib/hecks/projector/target.rb +1 -1
  65. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  66. data/lib/hecks/release/runner/commands.rb +68 -0
  67. data/lib/hecks/release/runner/console.rb +52 -0
  68. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  69. data/lib/hecks/release/runner/git.rb +48 -0
  70. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  71. data/lib/hecks/release/runner/preflight.rb +119 -0
  72. data/lib/hecks/release/runner/published.rb +55 -0
  73. data/lib/hecks/release/runner/tagger.rb +87 -0
  74. data/lib/hecks/release/runner.rb +244 -0
  75. data/lib/hecks/router/namespace_installer.rb +1 -1
  76. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  77. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  78. data/lib/hecks/runtime/entity_element.rb +1 -1
  79. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  80. data/lib/hecks/runtime/invocation.rb +1 -1
  81. data/lib/hecks/runtime/loader.rb +1 -1
  82. data/lib/hecks/runtime/outbox.rb +1 -1
  83. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  84. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  85. data/lib/hecks/runtime/reference_hop.rb +4 -4
  86. data/lib/hecks/runtime/registry/verification.rb +11 -11
  87. data/lib/hecks/runtime/registry.rb +8 -4
  88. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  89. data/lib/hecks/runtime/value/coercion.rb +3 -3
  90. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  91. data/lib/hecks/storehouse.rb +18 -12
  92. data/lib/hecks/version.rb +1 -1
  93. data/lib/hecks/vocabulary.rb +5 -0
  94. data/lib/hecks.rb +6 -1
  95. metadata +28 -58
  96. data/lib/hecks/bench/cli.rb +0 -74
  97. data/lib/hecks/bench/environment.rb +0 -100
  98. data/lib/hecks/bench/postgres_probe.rb +0 -41
  99. data/lib/hecks/bench/report.rb +0 -116
  100. data/lib/hecks/bench/ruby_runner.rb +0 -151
  101. data/lib/hecks/bench/run.rb +0 -55
  102. data/lib/hecks/bench/rust_runner.rb +0 -165
  103. data/lib/hecks/bench/stats.rb +0 -75
  104. data/lib/hecks/bench/suite.rb +0 -172
  105. data/lib/hecks/bench/workload.rb +0 -137
  106. data/lib/hecks/bench.rb +0 -28
  107. data/lib/hecks/codemod.rb +0 -443
  108. data/lib/hecks/corpus.rb +0 -479
  109. data/lib/hecks/doc/reference.rb +0 -585
  110. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  111. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  112. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  113. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  114. data/lib/hecks/fuzzing/differential.rb +0 -192
  115. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  116. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  117. data/lib/hecks/fuzzing/form_census.rb +0 -285
  118. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  119. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  120. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  121. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  122. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  123. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  124. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  125. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  126. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  127. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  128. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  129. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  130. data/lib/hecks/fuzzing/properties.rb +0 -267
  131. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  132. data/lib/hecks/fuzzing/replay.rb +0 -930
  133. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  134. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  135. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  136. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  137. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  138. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  139. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  140. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  141. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  142. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  143. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  144. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  145. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  146. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  147. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  148. data/lib/hecks/fuzzing.rb +0 -23
  149. data/lib/hecks/grammar/evolve.rb +0 -524
  150. data/lib/hecks/query_ir.rb +0 -482
@@ -324,7 +324,7 @@ module Hecks
324
324
  # Built outside the template heredoc so Layout/HeredocIndentation
325
325
  # cannot re-indent YAML that must match SessionSecretRead / env.
326
326
  # First interpolated line sits at the `\#{...}` column; later lines
327
- # get that same left pad (lambda.rb's own OAUTHPOLICY pattern).
327
+ # get that same left pad (lambda.rb's own `OAUTHPOLICY` pattern).
328
328
  oauth_task_policy_yaml = google_oauth_present ? <<~OAUTHPOLICY.rstrip : ""
329
329
  - PolicyName: GoogleOauthSecretRead
330
330
  PolicyDocument:
@@ -148,7 +148,7 @@ module Hecks
148
148
  # `stack_name` at all. They diverge on purpose for a domain whose
149
149
  # declared identity (`Hecks.bluebook`, hence `world.domain`, hence
150
150
  # `.world`'s own filename — `domain_name` above, needed just to find
151
- # that file) no longer matches its live AWS stack's own name: a
151
+ # that file) does not match its live AWS stack's own name: a
152
152
  # CloudFormation stack cannot be renamed in place, and this domain's
153
153
  # logical ids are already load-bearing on a real RDS instance other
154
154
  # domains borrow from in Shared mode (see `owner_stack_name` below) —
@@ -1246,7 +1246,7 @@ module Hecks
1246
1246
  template_yaml = template_yaml.sub(
1247
1247
  /^ - LambdaInvokePolicy:\n FunctionName: !Ref #{Regexp.escape(logical_id)}\n/, ""
1248
1248
  )
1249
- # Outputs — no `#{logical_id}Url` exists any more for `FunctionUrl` to
1249
+ # Outputs — no `#{logical_id}Url` exists for `FunctionUrl` to
1250
1250
  # `!GetAtt`; WebFunction's own URL becomes the stack's one and only
1251
1251
  # "FunctionUrl" (never "WebFunctionUrl" — that name is reserved for
1252
1252
  # the two-URL case, a domain that also has a #{logical_id} of its
@@ -2287,7 +2287,7 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
2287
2287
  reindent = ->(text) { text.each_line.map { |line| line.strip.empty? ? line : " #{line}" }.join }
2288
2288
 
2289
2289
  # Not pre-reindented (unlike the return value as a whole, below) — each
2290
- # is spliced back into the still-being-dedented RESOURCES heredoc via
2290
+ # is spliced back into the still-being-dedented `RESOURCES` heredoc via
2291
2291
  # `#{...}`, which the outer `reindent.call` already shifts by 2
2292
2292
  # spaces once; reindenting here too would double it.
2293
2293
  origin_access_control = use_oac ? <<~OAC : ""
@@ -1,7 +1,13 @@
1
- require_relative "../doc/reference"
2
1
  require_relative "../projector"
3
2
 
4
3
  module Hecks
4
+ # Repository-only tooling: `Doc::Reference` reads the committed pages under
5
+ # `docs/`, which only a checkout has, so it loads on first use and the
6
+ # packaged gem leaves it out (ADR 0066).
7
+ module Doc
8
+ autoload :Reference, File.expand_path("../doc/reference", __dir__)
9
+ end
10
+
5
11
  module Projections
6
12
  # The DSL reference pages, projected from the chapter's own Syntax
7
13
  # aggregate — the tables come from the declaration, the prose is
@@ -263,10 +263,10 @@ module Hecks
263
263
  # nothing this domain attaches provides payment_connection.
264
264
  # @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
265
265
  # @param domain_name [String] the domain to export the payment-connection binding for
266
- # @return [Hash{Symbol => String, nil}] `:provider` (name), one qualified verb per contract key
267
- # (`:connect`, `:reconnect`, `:disconnect`, `:suspend`, `:resume`, `:enable`, `:disable`) and
268
- # `:aggregate` (`:connect`'s own leading qualified aggregate name); `{}` if nothing this
269
- # domain attaches provides payment_connection
266
+ # @return [Hash{Symbol => String, nil}] `:provider` (name), one qualified verb per
267
+ # contract key (`:connect`, `:reconnect`, `:disconnect`, `:suspend`, `:resume`,
268
+ # `:enable`, `:disable`) and `:aggregate` (`:connect`'s own leading qualified
269
+ # aggregate name); `{}` if nothing this domain attaches provides payment_connection
270
270
  def payment_connection(registry, domain_name)
271
271
  provider = registry.payment_connection_provider_for(domain_name)
272
272
  return {} unless provider
@@ -59,7 +59,7 @@ module Hecks
59
59
  # rather than as a `raise` in the projection's own body, so the
60
60
  # requirement is stated instead of written as behaviour. The
61
61
  # registry refuses before the projection runs, so the projection
62
- # itself no longer carries a guard about its own admission.
62
+ # itself carries no guard about its own admission.
63
63
  # `emits:` says what kind of artifact comes back.
64
64
  #
65
65
  # `:artifact` (the default) is one thing — a Hash, or a String.
@@ -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