hecks 2.6.0 → 2.7.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/prism.rb +1 -1
  5. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  6. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  7. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  8. data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
  9. data/lib/hecks/bluebook/hexagon.rb +15 -7
  10. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  11. data/lib/hecks/bluebook/model_check.rb +1 -1
  12. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  13. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  14. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  15. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  16. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  17. data/lib/hecks/forms/value_object_shape.rb +1 -1
  18. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  19. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  20. data/lib/hecks/fuzzing/isolated_boot.rb +59 -5
  21. data/lib/hecks/language/world/world.bluebook +32 -2
  22. data/lib/hecks/ports/key_vault.rb +1 -1
  23. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  24. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
  25. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  26. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  27. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
  28. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  29. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  30. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  31. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  32. data/lib/hecks/ports/persistence.rb +4 -0
  33. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  34. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  35. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  36. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  37. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  38. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  39. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  40. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  41. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  42. data/lib/hecks/projections/deploy/fargate.rb +187 -138
  43. data/lib/hecks/projections/deploy/lambda.rb +5 -5
  44. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  45. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  46. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  47. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  48. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  49. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  50. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  51. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  52. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  53. data/lib/hecks/projections/deploy/preview.rb +103 -0
  54. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  55. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  56. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  57. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  58. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  59. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  60. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  61. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  62. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  63. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  64. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  65. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  66. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  67. data/lib/hecks/projections.rb +2 -0
  68. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  69. data/lib/hecks/release/gem_pin.rb +112 -0
  70. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  71. data/lib/hecks/runtime/loader.rb +26 -2
  72. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  73. data/lib/hecks/runtime/registry/verification.rb +4 -2
  74. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  75. data/lib/hecks/runtime/registry.rb +14 -9
  76. data/lib/hecks/runtime/tenant_check.rb +1 -1
  77. data/lib/hecks/vendoring/git_environment.rb +35 -0
  78. data/lib/hecks/vendoring/git_source.rb +102 -0
  79. data/lib/hecks/vendoring.rb +102 -0
  80. data/lib/hecks/version.rb +1 -1
  81. data/lib/hecks.rb +2 -1
  82. metadata +45 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2220925ed910b9c63dd34c3fc75448f6cff634a64be3fb67461985d462e83563
4
- data.tar.gz: 618319249d19f123171d300578ba8d70556391371457700504c92ba4bd389935
3
+ metadata.gz: a91dfe0fb86ceb9bb96ecc6b08da680213fed526f49f922f1a2f66d9cb89c80e
4
+ data.tar.gz: 22fc90a2e361f45e27796248c22391997281e62fbfeec01b25ba35378e6e5681
5
5
  SHA512:
6
- metadata.gz: a24cd51e0ba290e819645aec45cbc8a55c9978cb1c5fed673180db103a050a77516ebcd224d0102fad523161ce4776866b5702acf790dd0751282c2c399e73a9
7
- data.tar.gz: a779523871cd0e81d87c26305bed16e51916723ef544e55ccc30d669b6fdb38b825ee2a05515ae9f8c2f40ff31e4faa57f4d5dffc0dac68146d900d112830d92
6
+ metadata.gz: 824fd1d23971f6dacfd3c3ea18a35f5d0d61e7f07e2056b789d7be7d105a7bc47d13a61e0391d7aeb46d20bcfe8400e307bf05728ed10f9e4c69b46ac7d0df45
7
+ data.tar.gz: 3f6e4679c8187f212f913de1d17944c3bac2ed0e4385bcff3bcd259e1b6ccb49d6c8270c7f84885fcc3f308e9ca7def653341050c649d1a048b8a91228ee65e7
@@ -6,7 +6,7 @@ module Hecks
6
6
  module Adapters
7
7
  # **Google's own OIDC handshake** — the `authentication` port's one real
8
8
  # implementation today. Consolidates what was hand-rolled per-app
9
- # (an embryonaut_console `google_auth.rb` did this on its own) into
9
+ # (a console app's `google_auth.rb` did this on its own) into
10
10
  # one adapter, so any hecks-based app gets Google sign-in for free, the
11
11
  # same "one adapter, reusable everywhere" value every other adapter
12
12
  # in this directory already has.
@@ -29,7 +29,7 @@ module Hecks
29
29
  # exist precisely so a domain whose AWS identity predates a rename
30
30
  # keeps targeting the stack that is actually live rather than
31
31
  # standing up a second, empty one beside it. A real one:
32
- # embryonautfoundersapp deploys as `hecksagain-embryonaut`, and no
32
+ # a client site deploys under a fixed stack name, and no
33
33
  # value of `domain` (or of `DOMAIN_NAME`, the deployed-Lambda
34
34
  # override callers already pass) can make this computation produce
35
35
  # a name with no dash after "hecks". Every Ruby-side read and
@@ -72,7 +72,7 @@ module Hecks
72
72
  # That's invisible to anything that virtualizes the filesystem at
73
73
  # the Ruby level instead of the OS level (e.g. tebako's memfs,
74
74
  # which presses a hecks-based app into a single executable —
75
- # see domain/README.md's "Deploying" section in lifeadelics for
75
+ # see domain/README.md's "Deploying" section in a client site's domain for
76
76
  # why that matters). ::Prism.parse(File.read(file)) parses the
77
77
  # exact same bytes, just read through Ruby's File.read first,
78
78
  # which those tools do intercept.
@@ -82,6 +82,8 @@ module Hecks
82
82
  ["Type", "one_of"] => :one_of_impl,
83
83
  ["World", "realm"] => :realm_impl,
84
84
  ["World", "latest"] => :latest_impl,
85
+ ["World", "default_database"] => :default_database_impl,
86
+ ["World", "default_adapter"] => :default_adapter_impl,
85
87
  ["DomainPort", "operation"] => :tells_impl,
86
88
  ["DomainPort", "tells"] => :tells_impl,
87
89
  ["DomainPort", "asks"] => :asks_impl,
@@ -22,7 +22,7 @@ module Hecks
22
22
  # same as an absent `to:` and falls through to `from` (also
23
23
  # absent), so `then_set :accepted, to: false` raised "names no
24
24
  # operation" for the one value most likely to be written that way
25
- # (a boolean flip). Confirmed real, live: miette's
25
+ # (a boolean flip). Confirmed real, live: an external project's
26
26
  # dream_interpretation.bluebook (`then_set :accepted, to: false`)
27
27
  # and transparency.bluebook (`then_set :always, to: false`). TODO
28
28
  # upstream via bin/evolve.
@@ -262,7 +262,7 @@ module Hecks
262
262
  # era keeps booting, which is the whole point of the rename column.
263
263
  #
264
264
  # Vendored addition, not (yet) upstream hecks: `then_set
265
- # :target, from: :source_field` (hecks_conception/miette, found
265
+ # :target, from: :source_field` (an external project, found
266
266
  # live in body/doctor/bluebook/doctor.bluebook) -- semantically
267
267
  # identical to `to:` (copy this argument/field into the target),
268
268
  # different word. TODO upstream via bin/evolve (migration plan
@@ -271,7 +271,7 @@ module Hecks
271
271
  #
272
272
  # Vendored addition, not (yet) upstream hecks (migration plan
273
273
  # task 4, i106 in-DSL math): `multiply:`/`clamp:` -- per-tick organ
274
- # math (miette's body/organs/bluebook: strength decays ×0.98,
274
+ # math (an external project's organ bluebook: strength decays ×0.98,
275
275
  # weight/strength clamp to [0, 1]), moved into the bluebook itself
276
276
  # from shell-side awk. `multiply:` mirrors
277
277
  # increment/decrement's shape exactly (a Numeric amount, applied
@@ -34,7 +34,7 @@ module Hecks
34
34
  # only external facts with attribute" the refusal message
35
35
  # describes) rather than folding `to:` into identity_attribute's
36
36
  # existing Reference-attribute scan, which every operation already
37
- # in the corpus (Banking, pizzas, lifeadelics' own vendored
37
+ # in the corpus (Banking, pizzas, a client site's own vendored
38
38
  # PaymentGateway) still relies on unchanged.
39
39
  #
40
40
  # @param name [String] the operation's name
@@ -11,8 +11,17 @@ module Hecks
11
11
  # Starts with no settings recorded.
12
12
  def initialize = @values = {}
13
13
 
14
- def method_missing(key, *args, &)
15
- @values[key.to_sym] = args.size == 1 ? args.first : args
14
+ # A call with a block records the block's own settings as a nested Hash, so a bind can
15
+ # group settings (`preview do ... end`); a call without one records its argument.
16
+ def method_missing(key, *args, &block)
17
+ @values[key.to_sym] =
18
+ if block
19
+ nested = SettingsCollector.new
20
+ nested.instance_eval(&block)
21
+ nested.to_h
22
+ else
23
+ args.size == 1 ? args.first : args
24
+ end
16
25
  end
17
26
 
18
27
  def respond_to_missing?(_name, _include_private = false) = true
@@ -96,6 +105,35 @@ module Hecks
96
105
  @latest = required(value, "latest version")
97
106
  end
98
107
 
108
+ # Names the connection every chapter's database-taking persistence adapter uses
109
+ # unless that chapter's own world settings name one.
110
+ #
111
+ # Answers the `default_database` word through the table's `calls:` column, the
112
+ # same way `realm_impl` does. A chapter's own
113
+ # `persisted_by("Adapter") { database ... }` still wins for that adapter.
114
+ #
115
+ # @param value [String] the connection, such as a database URL; blankness is
116
+ # judged by the world language at `build`, not here
117
+ # @return [String] the connection as stored
118
+ def default_database_impl(value)
119
+ @default_database = required(value, "default database")
120
+ end
121
+
122
+ # Names the persistence adapter every aggregate binds to unless its own chapter's
123
+ # hecksagon binds it.
124
+ #
125
+ # Answers the `default_adapter` word through the table's `calls:` column, the same
126
+ # way `realm_impl` does. Sits between an explicit bind and the framework's
127
+ # in-memory fallback; whether the adapter really is a persistence adapter is
128
+ # judged at boot, where adapters are known.
129
+ #
130
+ # @param value [String, Symbol] the adapter's declared name, such as `"PostgresEra"`;
131
+ # blankness is judged by the world language at `build`, not here
132
+ # @return [String] the adapter name as stored
133
+ def default_adapter_impl(value)
134
+ @default_adapter = required(value, "default adapter")
135
+ end
136
+
99
137
  def method_missing(verb, *args, **kwargs, &block)
100
138
  result = word_gate_dispatch(verb, args, kwargs, block)
101
139
  return result unless result.equal?(WordGate::NOT_ADMITTED)
@@ -134,7 +172,8 @@ module Hecks
134
172
  # as a blank `realm`
135
173
  def build
136
174
  MetaValidator.call_world(
137
- World.new(domain: @domain, realm: @realm, latest: @latest, settings: @settings)
175
+ World.new(domain: @domain, realm: @realm, latest: @latest, settings: @settings,
176
+ default_database: @default_database, default_adapter: @default_adapter)
138
177
  )
139
178
  end
140
179
 
@@ -97,9 +97,10 @@ module Hecks
97
97
  include Hecks::IR
98
98
  include Behaviour::World
99
99
 
100
- emits_ir(domain: :domain, realm: :realm, latest: :latest, settings: :settings)
100
+ emits_ir(domain: :domain, realm: :realm, latest: :latest, settings: :settings,
101
+ default_database: :default_database, default_adapter: :default_adapter)
101
102
 
102
- attr_reader :domain, :realm, :latest, :settings
103
+ attr_reader :domain, :realm, :latest, :settings, :default_database, :default_adapter
103
104
 
104
105
  # @param domain [String, Symbol] the domain this world configures
105
106
  # @param realm [String, Symbol, nil] the declared realm/version marker, or `nil`
@@ -108,11 +109,18 @@ module Hecks
108
109
  # if none is declared
109
110
  # @param settings [Hash] the declared adapter bind settings, keyed by verb and,
110
111
  # for a qualified entry, `"verb:adapter"`
111
- def initialize(domain:, realm: nil, latest: nil, settings: {})
112
- @domain = domain.to_s
113
- @realm = realm&.to_s
114
- @latest = latest&.to_s
115
- @settings = settings
112
+ # @param default_database [String, nil] the connection every chapter's
113
+ # database-taking persistence adapter uses unless its own settings name one,
114
+ # or `nil` if none is declared
115
+ # @param default_adapter [String, nil] the persistence adapter every aggregate
116
+ # binds to unless its chapter's hecksagon binds it, or `nil` if none is declared
117
+ def initialize(domain:, realm: nil, latest: nil, settings: {}, default_database: nil, default_adapter: nil)
118
+ @domain = domain.to_s
119
+ @realm = realm&.to_s
120
+ @latest = latest&.to_s
121
+ @settings = settings
122
+ @default_database = default_database&.to_s
123
+ @default_adapter = default_adapter&.to_s
116
124
  end
117
125
  end
118
126
  end
@@ -40,7 +40,9 @@ module Hecks
40
40
  def judge!
41
41
  domain = @world.domain
42
42
  send_to("World::World.Declare", domain, domain: v(domain),
43
- realm: v(@world.realm), latest: v(@world.latest))
43
+ realm: v(@world.realm), latest: v(@world.latest),
44
+ default_database: v(@world.default_database),
45
+ default_adapter: v(@world.default_adapter))
44
46
 
45
47
  Hash(@world.settings).each do |verb, values|
46
48
  # the DSL records each binding twice — once by verb, once by
@@ -793,7 +793,7 @@ module Hecks
793
793
 
794
794
  # **Typo detection, deliberately weaker** — `known_domains` can only
795
795
  # ever be a monorepo-scoped heuristic: a real external hecks
796
- # consumer's own domain (this repo's own embryonaut/lifeadelics-
796
+ # consumer's own domain (this repo's own embryonaut client-
797
797
  # shaped case) lives in a genuinely separate repository this
798
798
  # corpus scan can never see, so a target this check cannot find
799
799
  # is "unknown to THIS corpus," never proof of a typo. A target
@@ -0,0 +1,72 @@
1
+ require "digest"
2
+
3
+ module Hecks
4
+ module EmbryonautBluebook
5
+ # What a vendored package records about the release it came from:
6
+ # `vendor/embryonaut_bluebooks/<name>/bluebook.lock`.
7
+ #
8
+ # ## File format
9
+ #
10
+ # One `key: value` line each for `package`, `version`, `tag`, `commit`
11
+ # and `digest`, then one `shape: <Domain> <label>` line per domain:
12
+ #
13
+ # package: payments
14
+ # version: 1.2.0
15
+ # tag: payments-v1.2.0
16
+ # commit: <full commit id the tag points at>
17
+ # digest: <sha256 of the vendored *.bluebook files>
18
+ # shape: Payments d33c23
19
+ #
20
+ # The digest is the sha256 of the `<sha256> <name>` lines of every
21
+ # top-level `*.bluebook` file, sorted by name. The source repository's own
22
+ # `bin/bluebook_digest` prints the same number for the tagged release, which
23
+ # is how a vendored copy is proven to be exactly what it claims to be.
24
+ Lock = Struct.new(:package, :version, :tag, :commit, :digest, :shape, keyword_init: true) do
25
+ # Computes the content digest of a directory's `*.bluebook` files.
26
+ #
27
+ # @param dir [String] the directory to measure
28
+ # @return [String, nil] 64 lowercase hex characters, nil when the directory holds
29
+ # no bluebook file
30
+ def self.digest_of(dir)
31
+ names = Dir.children(dir).select { |name| name.end_with?(".bluebook") }.sort
32
+ return nil if names.empty?
33
+
34
+ lines = names.map { |name| "#{Digest::SHA256.file(File.join(dir, name)).hexdigest} #{name}\n" }
35
+ Digest::SHA256.hexdigest(lines.join)
36
+ end
37
+
38
+ # Reads a lock file.
39
+ #
40
+ # @param path [String] the `bluebook.lock` path
41
+ # @return [Lock, nil] the parsed lock, nil when the file does not exist
42
+ def self.read(path)
43
+ return nil unless File.file?(path)
44
+
45
+ parse(File.read(path))
46
+ end
47
+
48
+ # Parses a lock's text.
49
+ #
50
+ # @param text [String] the file's content
51
+ # @return [Lock] the lock, with `shape` as an Array of lines (empty when none)
52
+ def self.parse(text)
53
+ fields = { shape: [] }
54
+ text.each_line do |line|
55
+ key, _, value = line.chomp.partition(": ")
56
+ next if key.empty?
57
+
58
+ key == "shape" ? fields[:shape] << value : fields[key.to_sym] = value
59
+ end
60
+ new(**fields.slice(*members))
61
+ end
62
+
63
+ # Renders the lock as the text written to `bluebook.lock`.
64
+ #
65
+ # @return [String] the lines described above, newline terminated
66
+ def to_s
67
+ header = %i[package version tag commit digest].map { |key| "#{key}: #{self[key]}\n" }
68
+ (header + Array(shape).map { |line| "shape: #{line}\n" }).join
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,47 @@
1
+ require_relative "../ports/persistence/plugins/era/storage_shape"
2
+
3
+ module Hecks
4
+ module EmbryonautBluebook
5
+ # The storage-shape labels of a directory of `*.bluebook` files.
6
+ #
7
+ # A label is what `PostgresEra` mints an era from, so a vendored package
8
+ # whose label changed is one whose next deploy mints a new era. This reads
9
+ # the files the way the era guard reads a held era's text: parse only, no
10
+ # boot, no wiring, no database, each file loaded into a scratch registry.
11
+ module Shape
12
+ module_function
13
+
14
+ # Measures every domain a directory's bluebook files declare.
15
+ #
16
+ # @param dir [String] a directory holding `*.bluebook` files
17
+ # @return [Array<String>] one `"<Domain> <label>"` line per domain, sorted by domain name
18
+ # @raise [Vendoring::Error] if the directory holds no bluebook file, or the files do
19
+ # not load, the refusal a boot would raise
20
+ def labels(dir)
21
+ files = Dir[File.join(dir, "*.bluebook")]
22
+ raise Vendoring::Error, "no *.bluebook files in #{dir}" if files.empty?
23
+
24
+ load_all(files).bluebooks.sort_by { |name, _| name.to_s }.map do |name, bluebook|
25
+ "#{name} #{Runtime::StorageShape.mint_label(bluebook)}"
26
+ end
27
+ end
28
+
29
+ # Loads bluebook files into a registry of their own.
30
+ #
31
+ # @param files [Array<String>] absolute bluebook file paths, in load order
32
+ # @return [Runtime::Registry] the scratch registry holding what the files declared
33
+ # @raise [Vendoring::Error] if any file fails to load
34
+ def load_all(files)
35
+ scratch = Runtime::Registry.new
36
+ library = Ports::Loading.bootstrap
37
+ Hecks.with_registry(scratch) do
38
+ library.load_library
39
+ files.each { |file| Kernel.load(file) }
40
+ end
41
+ scratch
42
+ rescue StandardError, ScriptError => e
43
+ raise Vendoring::Error, "the bluebook files do not load: #{e.class}: #{e.message.lines.first.to_s.strip}"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,194 @@
1
+ require "fileutils"
2
+ require_relative "../vendoring"
3
+ require_relative "lock"
4
+ require_relative "shape"
5
+
6
+ module Hecks
7
+ module EmbryonautBluebook
8
+ # Vendors one package of the bluebook registry into a consuming project.
9
+ #
10
+ # ## What it does
11
+ #
12
+ # Pins the package's top-level `bluebook/*.bluebook` files from one commit
13
+ # of a local source repository into
14
+ # `<root>/vendor/embryonaut_bluebooks/<name>/bluebook/`, where `load!`
15
+ # resolves them. Only bluebook files are taken: a `.hecksagon`, `.port` or
16
+ # `.adapter` is a wiring decision for whoever deploys, never part of the
17
+ # vendored package.
18
+ #
19
+ # ## Releases and bare commits
20
+ #
21
+ # The source repository tags each release `<name>-v<X.Y.Z>` on a commit
22
+ # whose `<name>/bluebook.yml` says that version. A release pin (`ref` nil for
23
+ # the newest tag, `"1.2.0"`, or the tag name) also writes `bluebook.lock`,
24
+ # which records version, tag, commit, content digest and storage shape, and
25
+ # holds two refusals, because a production project is bound to `PostgresEra`:
26
+ #
27
+ # - a version lower than the vendored one, unless `allow_downgrade`;
28
+ # - a storage-shape change that does not raise the version by at least a
29
+ # minor, so a new era shows in the version number and not only in a hash.
30
+ #
31
+ # Any other commit-ish pins that commit and writes only the marker, with no
32
+ # lock and no version checks. Either way nothing on disk changes when a
33
+ # pin is refused or its files do not load.
34
+ class Vendor
35
+ # A release version, `X.Y.Z`.
36
+ VERSION = /\A\d+\.\d+\.\d+\z/
37
+
38
+ # A package name is a directory name and a bluebook name stem.
39
+ NAME = /\A[a-z][a-z0-9_]*\z/
40
+
41
+ # What a vendoring changed.
42
+ #
43
+ # @!attribute [r] previous_version
44
+ # @return [String, nil] the version recorded before, nil when there was none
45
+ # @!attribute [r] previous_shape
46
+ # @return [Array<String>, nil] the shape lines measured before, nil when there was
47
+ # no earlier copy
48
+ Result = Struct.new(:package, :commit, :version, :tag, :digest, :shape,
49
+ :previous_version, :previous_shape, :dir, keyword_init: true) do
50
+ # Says whether this was a release pin, which writes a lock.
51
+ #
52
+ # @return [Boolean] true for a release, false for a bare commit
53
+ def release? = !tag.nil?
54
+
55
+ # Says whether the storage shape moved since the earlier copy.
56
+ #
57
+ # @return [Boolean] true when an earlier copy existed and its shape differs
58
+ def shape_changed? = !previous_shape.nil? && previous_shape != shape
59
+ end
60
+
61
+ # @param name [String, Symbol] the package's directory name, such as `"payments"`
62
+ # @param from [String] path of the local source repository
63
+ # @param ref [String, nil] `X.Y.Z`, a release tag name, any commit-ish, or nil for the
64
+ # newest release
65
+ # @param root [String] the consuming project's root
66
+ # @param allow_downgrade [Boolean] whether a release older than the vendored one is allowed
67
+ # @raise [Vendoring::Error] if the name is not a plain package name
68
+ def initialize(name, from:, root:, ref: nil, allow_downgrade: false)
69
+ @name = name.to_s
70
+ raise Vendoring::Error, "#{name.inspect} is not a package name" unless @name.match?(NAME)
71
+
72
+ @source = Vendoring::GitSource.new(from)
73
+ @ref = ref&.to_s
74
+ @root = root
75
+ @allow_downgrade = allow_downgrade
76
+ end
77
+
78
+ # Pins the package and reports what changed.
79
+ #
80
+ # @return [Result] the commit, version and shape now vendored
81
+ # @raise [Vendoring::Error] if the release or commit is not found, the tagged commit
82
+ # disagrees with its own version, the files do not load, or a refusal above applies
83
+ def call
84
+ tag, version = release
85
+ previous_version, previous_shape = existing
86
+ shape = nil
87
+ pin = Vendoring.pin(from: @source.path, ref: tag || @ref, subtree: "#{@name}/bluebook",
88
+ into: package_dir, glob: "*.bluebook") do |staged, commit|
89
+ shape = Shape.labels(staged)
90
+ next nil unless tag
91
+
92
+ refuse_unless_acceptable!(version, previous_version, previous_shape, shape)
93
+ { "bluebook.lock" => lock(version, tag, commit, staged, shape).to_s }
94
+ end
95
+ build_result(pin, tag, version, shape, previous_version, previous_shape)
96
+ end
97
+
98
+ private
99
+
100
+ def package_dir = File.join(@root, "vendor", "embryonaut_bluebooks", @name)
101
+
102
+ # Resolves what `ref` asks for into a release tag and its version.
103
+ #
104
+ # @return [Array(String, String), nil] tag and version for a release, nil for a bare commit
105
+ def release
106
+ tag = release_tag
107
+ return nil unless tag
108
+
109
+ version = tag.delete_prefix("#{@name}-v")
110
+ tagged = @source.read(tag, "#{@name}/bluebook.yml")&.[](/^version: *(\S+)/, 1)
111
+ unless tagged == version
112
+ raise Vendoring::Error, "#{tag} points at a commit whose bluebook.yml says version #{tagged.inspect}"
113
+ end
114
+
115
+ [tag, version]
116
+ end
117
+
118
+ def release_tag
119
+ return newest_release_tag if @ref.nil?
120
+ return existing_tag("#{@name}-v#{@ref}") if @ref.match?(VERSION)
121
+
122
+ @ref.match?(/\A#{Regexp.escape(@name)}-v\d+\.\d+\.\d+\z/) ? existing_tag(@ref) : nil
123
+ end
124
+
125
+ def newest_release_tag
126
+ tags = release_tags
127
+ raise Vendoring::Error, "no #{@name}-v* release tag in #{@source.path}" if tags.empty?
128
+
129
+ "#{@name}-v#{tags.max_by { |version| Gem::Version.new(version) }}"
130
+ end
131
+
132
+ def existing_tag(tag)
133
+ return tag if release_tags.include?(tag.delete_prefix("#{@name}-v"))
134
+
135
+ raise Vendoring::Error, "no release #{tag} in #{@source.path} (releases: #{release_tags.sort_by do |v|
136
+ Gem::Version.new(v)
137
+ end.join(' ')})"
138
+ end
139
+
140
+ def release_tags
141
+ @source.tags("#{@name}-v*").map { |tag| tag.delete_prefix("#{@name}-v") }.grep(VERSION)
142
+ end
143
+
144
+ # The version and storage shape of what is vendored now, measured from
145
+ # its files as they stand rather than read back, so a copy vendored before
146
+ # locks existed still gets a real "before".
147
+ def existing
148
+ version = Lock.read(File.join(package_dir, "bluebook.lock"))&.version
149
+ dir = File.join(package_dir, "bluebook")
150
+ shape = File.directory?(dir) ? Shape.labels(dir) : nil
151
+ [version, shape]
152
+ rescue Vendoring::Error
153
+ [version, nil]
154
+ end
155
+
156
+ def refuse_unless_acceptable!(version, previous_version, previous_shape, shape)
157
+ return unless previous_version
158
+
159
+ refuse_downgrade!(version, previous_version)
160
+ return if previous_shape.nil? || previous_shape == shape
161
+ return if minor_or_more?(previous_version, version)
162
+
163
+ raise Vendoring::Error,
164
+ "#{@name} #{previous_version} -> #{version} changes the storage shape but is only a patch bump " \
165
+ "(before: #{previous_shape.join(' ')}; after: #{shape.join(' ')}). Raise the package's version " \
166
+ "by at least a minor in the source repository and release again. Nothing was changed."
167
+ end
168
+
169
+ def refuse_downgrade!(version, previous_version)
170
+ return if @allow_downgrade || Gem::Version.new(version) >= Gem::Version.new(previous_version)
171
+
172
+ raise Vendoring::Error, "#{@name} #{previous_version} is vendored; #{version} is older. " \
173
+ "Pass allow_downgrade to do it anyway."
174
+ end
175
+
176
+ def minor_or_more?(old, new)
177
+ old_major, old_minor = old.split(".").map(&:to_i)
178
+ new_major, new_minor = new.split(".").map(&:to_i)
179
+ new_major > old_major || (new_major == old_major && new_minor > old_minor)
180
+ end
181
+
182
+ def lock(version, tag, commit, staged, shape)
183
+ Lock.new(package: @name, version: version, tag: tag, commit: commit,
184
+ digest: Lock.digest_of(staged), shape: shape)
185
+ end
186
+
187
+ def build_result(pin, tag, version, shape, previous_version, previous_shape)
188
+ Result.new(package: @name, commit: pin.commit, version: version, tag: tag,
189
+ digest: Lock.digest_of(pin.dir), shape: shape, dir: pin.dir,
190
+ previous_version: previous_version, previous_shape: previous_shape)
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,88 @@
1
+ require "optparse"
2
+ require_relative "vendor"
3
+
4
+ module Hecks
5
+ module EmbryonautBluebook
6
+ # The command line behind `bin/vendor_bluebook`.
7
+ #
8
+ # It lives in the gem, not in a script, so a consuming project reaches it
9
+ # through its own bundle without a copy of the script:
10
+ #
11
+ # bundle exec ruby -rhecks \
12
+ # -e 'exit Hecks::EmbryonautBluebook::VendorCli.run(ARGV)' payments@1.2.0
13
+ #
14
+ # ## Usage
15
+ #
16
+ # vendor_bluebook <package>[@<version-or-commit>] [--from <path>] [--root <path>]
17
+ #
18
+ # `--from` defaults to `$EMBRYONAUT_BLUEBOOKS_SRC` and `--root` to the current
19
+ # directory. `ALLOW_DOWNGRADE=1` in the environment permits a release older
20
+ # than the vendored one.
21
+ module VendorCli
22
+ USAGE = "usage: vendor_bluebook <package>[@<version-or-commit>] [--from PATH] [--root PATH]".freeze
23
+
24
+ # Runs the command and reports on the given streams.
25
+ #
26
+ # @param argv [Array<String>] the command-line arguments
27
+ # @param env [Hash{String => String}] the environment
28
+ # @param out [IO] where the report goes
29
+ # @param err [IO] where a refusal goes
30
+ # @param root [String] the default project root
31
+ # @return [Integer] the exit status, 0 when the package was vendored
32
+ def self.run(argv, env: ENV, out: $stdout, err: $stderr, root: Dir.pwd)
33
+ options = { from: env["EMBRYONAUT_BLUEBOOKS_SRC"], root: root }
34
+ spec = parse(argv, options)
35
+ return usage(err) unless spec && options[:from]
36
+
37
+ name, _, ref = spec.partition("@")
38
+ result = Vendor.new(name, from: options[:from], root: options[:root], ref: ref.empty? ? nil : ref,
39
+ allow_downgrade: env["ALLOW_DOWNGRADE"] == "1").call
40
+ report(result, options[:from], out)
41
+ 0
42
+ rescue Vendoring::Error, OptionParser::ParseError => e
43
+ err.puts(e.message)
44
+ 1
45
+ end
46
+
47
+ # Reads the options into a hash and picks out the package argument.
48
+ #
49
+ # @param argv [Array<String>] the command-line arguments
50
+ # @param options [Hash{Symbol => String}] receives `:from` and `:root` when given
51
+ # @return [String, nil] the one positional argument, nil unless exactly one was given
52
+ def self.parse(argv, options)
53
+ parser = OptionParser.new
54
+ parser.on("--from PATH") { |path| options[:from] = path }
55
+ parser.on("--root PATH") { |path| options[:root] = path }
56
+ rest = parser.parse(argv)
57
+ rest.first if rest.length == 1
58
+ end
59
+
60
+ def self.usage(err)
61
+ err.puts(USAGE)
62
+ 2
63
+ end
64
+
65
+ def self.report(result, from, out)
66
+ label = result.release? ? "#{result.version} (#{result.tag}, #{result.commit[0, 7]})" : result.commit[0, 12]
67
+ out.puts("Vendored embryonaut_bluebooks/#{result.package} #{label} from #{from} into #{result.dir}")
68
+ out.puts(shape_line(result))
69
+ end
70
+
71
+ # The shape label tells a re-vendor that only moved the pin apart from one
72
+ # that will mint a new era on the next deploy.
73
+ def self.shape_line(result)
74
+ now = result.shape.join(" ")
75
+ if result.previous_shape.nil?
76
+ "Shape: #{now} (no earlier vendored copy to compare against)"
77
+ elsif result.shape_changed?
78
+ "SHAPE CHANGED: the next deploy mints a new era; add its translation edge first.\n " \
79
+ "before: #{result.previous_shape.join(' ')}\n after: #{now}"
80
+ else
81
+ "Shape unchanged: #{now}. No new era on deploy."
82
+ end
83
+ end
84
+
85
+ private_class_method :parse, :usage, :report, :shape_line
86
+ end
87
+ end
88
+ end