hecks 3.11.0 → 3.13.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
  3. data/lib/hecks/adapters/driven/script_runner.rb +93 -0
  4. data/lib/hecks/adapters/driven.rb +1 -0
  5. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  6. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  7. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  8. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  9. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  10. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  11. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  12. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  13. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
  14. data/lib/hecks/bluebook/meta_validator.rb +28 -21
  15. data/lib/hecks/bluebook.rb +1 -0
  16. data/lib/hecks/deploy/bluebook/deploy.bluebook +17 -3
  17. data/lib/hecks/framework/bluebook/change.behaviors +30 -0
  18. data/lib/hecks/framework/bluebook/change.bluebook +162 -0
  19. data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
  20. data/lib/hecks/framework/change.oidc.json +34 -0
  21. data/lib/hecks/framework/console_settings.oidc.json +103 -0
  22. data/lib/hecks/framework/governance.oidc.json +28 -0
  23. data/lib/hecks/framework/identity.oidc.json +18 -0
  24. data/lib/hecks/framework/privacy.oidc.json +30 -0
  25. data/lib/hecks/gate/stages.yml +35 -0
  26. data/lib/hecks/grammar/translation.oidc.json +48 -0
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
  28. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
  29. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  30. data/lib/hecks/hecks/hecks.world +3 -0
  31. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  32. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  33. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  34. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  35. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  36. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  37. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  38. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  39. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  40. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  41. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  42. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  43. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  44. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  45. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  46. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  47. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  48. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  49. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  50. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  51. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  52. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  53. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  54. data/lib/hecks/projections/deploy/box.rb +15 -3
  55. data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
  56. data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
  57. data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
  58. data/lib/hecks/projections/site/edge/checks.rb +2 -0
  59. data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
  60. data/lib/hecks/projections/site/edge/function_code.rb +86 -0
  61. data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
  62. data/lib/hecks/projections/site/edge.rb +35 -9
  63. data/lib/hecks/projections/site/site_cdn.rb +21 -7
  64. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  65. data/lib/hecks/projections/site/table/row_builder.rb +12 -2
  66. data/lib/hecks/projections/site/table.rb +4 -2
  67. data/lib/hecks/projector/cli_audience.rb +20 -0
  68. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  69. data/lib/hecks/rust_build/host.rb +11 -4
  70. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  71. data/lib/hecks/site/bluebook/site.bluebook +16 -1
  72. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  73. data/lib/hecks/tools/ci_gates.rb +20 -1
  74. data/lib/hecks/tools/lanes/files.rb +8 -0
  75. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  76. data/lib/hecks/tools/lanes.rb +1 -0
  77. data/lib/hecks/tools/oidc_manifests.rb +44 -6
  78. data/lib/hecks/tools/release_only.rb +111 -0
  79. data/lib/hecks/tools/site_routes/templates.rb +13 -9
  80. data/lib/hecks/version.rb +1 -1
  81. data/lib/hecks/vocabulary.rb +15 -1
  82. data/prebuilt/2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal +0 -0
  83. data/prebuilt/58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal +0 -0
  84. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
  85. data/rust/host/HECKS_RELEASE +1 -1
  86. data/rust/host/src/auth.rs +58 -0
  87. data/rust/host/src/extension.rs +13 -1
  88. data/rust/host/src/lib.rs +1 -0
  89. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  90. data/rust/host/src/platform_signin/config.rs +512 -0
  91. data/rust/host/src/platform_signin/cookie.rs +237 -0
  92. data/rust/host/src/platform_signin/extension.rs +190 -0
  93. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  94. data/rust/host/src/platform_signin/handler.rs +251 -0
  95. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  96. data/rust/host/src/platform_signin/jti.rs +196 -0
  97. data/rust/host/src/platform_signin/jwks.rs +542 -0
  98. data/rust/host/src/platform_signin/jws.rs +249 -0
  99. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  100. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  101. data/rust/host/src/platform_signin/mod.rs +75 -0
  102. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  103. data/rust/host/src/platform_signin/pages.rs +204 -0
  104. data/rust/host/src/platform_signin/query.rs +233 -0
  105. data/rust/host/src/platform_signin/session.rs +151 -0
  106. data/rust/host/src/platform_signin/settings.rs +385 -0
  107. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  108. data/rust/host/src/platform_signin/test_support.rs +235 -0
  109. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  110. data/rust/host/src/platform_signin/verify.rs +604 -0
  111. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  112. data/rust/host/src/web.rs +50 -6
  113. data/rust/parser/src/parse/value_object.rs +22 -10
  114. metadata +59 -7
  115. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  116. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
  117. /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
  118. /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
@@ -7,6 +7,7 @@ require_relative "edge/row_reader"
7
7
  require_relative "edge/checks"
8
8
  require_relative "edge/behaviours"
9
9
  require_relative "edge/rules"
10
+ require_relative "edge/function_code"
10
11
 
11
12
  module Hecks
12
13
  module Projections
@@ -16,17 +17,33 @@ module Hecks
16
17
  #
17
18
  # The routes are the table's own `Route` rows. What a table cannot know, the names a template
18
19
  # gives its policies, origins, target groups and listener, a project declares as `member`
19
- # rows of four more value objects of the same chapter: `Edge` (the template and listener),
20
- # `EdgePolicy`, `EdgeOrigin` and `EdgeRule`. `docs/site-routes.md` lists their fields. A
21
- # project that declares none of the four has no edge to project.
20
+ # rows of five more value objects of the same chapter: `Edge` (the template and listener),
21
+ # `EdgePolicy`, `EdgeOrigin`, `EdgeRule` and `EdgeFunction` (a viewer-request function a route
22
+ # names with `edge:`). `docs/site-routes.md` lists their fields. A project that declares none
23
+ # of the five has no edge to project.
22
24
  class Edge
23
25
  Setting = Struct.new(:template, :listener, :secret_header, :secret_value, :alb, keyword_init: true)
24
26
  Policy = Struct.new(:cache_class, :origin, :cache, :origin_request, :response_headers, keyword_init: true)
25
27
  Upstream = Struct.new(:origin, :id, :target_group, keyword_init: true)
26
28
  Rule = Struct.new(:rule, :priority, :origin, keyword_init: true)
27
29
 
30
+ # A function the generator writes: `kind` says which, and the rest are its settings, the
31
+ # lists as comma-separated text.
32
+ Function = Struct.new(:name, :kind, :host, :aliases, :paths, keyword_init: true) do
33
+ # @return [Array<String>] the alias hosts that are redirected
34
+ def alias_list = split(aliases)
35
+
36
+ # @return [Array<String>] the exact paths the function watches
37
+ def path_list = split(paths)
38
+
39
+ private
40
+
41
+ def split(text) = text.to_s.split(",").map(&:strip).reject(&:empty?)
42
+ end
43
+
28
44
  # The value objects a project declares its edge rows in, by the kind of record each holds.
29
- OBJECTS = { setting: "Edge", policy: "EdgePolicy", upstream: "EdgeOrigin", rule: "EdgeRule" }.freeze
45
+ OBJECTS = { setting: "Edge", policy: "EdgePolicy", upstream: "EdgeOrigin", rule: "EdgeRule",
46
+ function: "EdgeFunction" }.freeze
30
47
 
31
48
  # The fields each kind of row may carry, with the Ruby class each takes.
32
49
  FIELDS = {
@@ -34,14 +51,15 @@ module Hecks
34
51
  policy: { cache_class: String, origin: String, cache: String, origin_request: String,
35
52
  response_headers: String },
36
53
  upstream: { origin: String, id: String, target_group: String },
37
- rule: { rule: String, priority: Integer, origin: String }
54
+ rule: { rule: String, priority: Integer, origin: String },
55
+ function: { name: String, kind: String, host: String, aliases: String, paths: String }
38
56
  }.freeze
39
57
 
40
58
  # The fields a row of each kind must carry. The template may instead be named to the tool.
41
59
  REQUIRED = { setting: [], policy: %i[cache_class cache], upstream: %i[origin id],
42
- rule: %i[rule priority origin] }.freeze
60
+ rule: %i[rule priority origin], function: %i[name kind host aliases paths] }.freeze
43
61
 
44
- STRUCTS = { setting: Setting, policy: Policy, upstream: Upstream, rule: Rule }.freeze
62
+ STRUCTS = { setting: Setting, policy: Policy, upstream: Upstream, rule: Rule, function: Function }.freeze
45
63
 
46
64
  # @return [Setting] the template and the listener
47
65
  attr_reader :setting
@@ -51,6 +69,8 @@ module Hecks
51
69
  attr_reader :upstreams
52
70
  # @return [Array<Rule>] the listener rules
53
71
  attr_reader :rules
72
+ # @return [Array<Function>] the edge functions the generator writes
73
+ attr_reader :functions
54
74
  # @return [Hash{Symbol => Object}] the default behaviour as `Cdn.behavior_lines` takes it
55
75
  attr_reader :default_behaviour
56
76
  # @return [Array<Hash{Symbol => Object}>] the other behaviours, in matching order
@@ -65,11 +85,12 @@ module Hecks
65
85
  # @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
66
86
  # @param template [String, nil] the template the caller names, which stands in for the
67
87
  # `Edge` row's own `template:`
68
- # @return [Edge, nil] the checked edge, or nil when the chapter declares none
88
+ # @return [Edge, nil] the checked edge, or nil when the chapter declares none and no route
89
+ # names an edge function
69
90
  # @raise [Table::Invalid] when a row is refused or the edge contradicts the routes
70
91
  def self.read(chapter, table:, vocabulary:, template: nil)
71
92
  declared = OBJECTS.transform_values { |name| Table.rows_of(chapter, name) }
72
- return nil if declared.values.all?(&:empty?)
93
+ return nil if declared.values.all?(&:empty?) && table.rows.none?(&:edge)
73
94
 
74
95
  problems = []
75
96
  records = declared.to_h { |kind, members| [kind, build(kind, members, problems)] }
@@ -104,6 +125,7 @@ module Hecks
104
125
  @policies = records.fetch(:policy)
105
126
  @upstreams = records.fetch(:upstream)
106
127
  @rules = records.fetch(:rule)
128
+ @functions = records.fetch(:function)
107
129
  end
108
130
  private :assign
109
131
 
@@ -119,6 +141,10 @@ module Hecks
119
141
  # unless the `Edge` row says `alb: false`
120
142
  def alb? = setting.nil? || setting.alb != false
121
143
 
144
+ # @param name [String] a logical id
145
+ # @return [Function, nil] the function row of that name
146
+ def function(name) = functions.find { |candidate| candidate.name == name }
147
+
122
148
  # @param origin [String] an origin of the Origin set
123
149
  # @return [Upstream, nil] its CloudFront origin and target group
124
150
  def upstream(origin) = upstreams.find { |candidate| candidate.origin == origin }
@@ -7,12 +7,14 @@ require_relative "edge"
7
7
  module Hecks
8
8
  module Projections
9
9
  module Site
10
- # The edge of a site's route table as the two blocks of a CloudFormation template that depend
11
- # on it: the distribution's cache behaviours and the load balancer's listener rules.
10
+ # The edge of a site's route table as the blocks of a CloudFormation template that depend on
11
+ # it: the distribution's cache behaviours, the load balancer's listener rules and the
12
+ # functions the behaviours attach.
12
13
  #
13
- # The blocks are returned by name, `"behaviors"` and `"listener_rules"`, as text from the
14
- # left margin; a project whose `Edge` row says `alb: false` has no load balancer and gets
15
- # `"behaviors"` alone. `Regions` puts each between the markers of the same name in the
14
+ # The blocks are returned by name, `"behaviors"`, `"listener_rules"` and `"edge_functions"`,
15
+ # as text from the left margin; a project whose `Edge` row says `alb: false` has no load
16
+ # balancer and gets no `"listener_rules"`, and one with no `EdgeFunction` row gets no
17
+ # `"edge_functions"`. `Regions` puts each between the markers of the same name in the
16
18
  # project's template, at the markers' indentation. The text is a pure function of the table
17
19
  # and the edge rows.
18
20
  module SiteCdn
@@ -34,9 +36,21 @@ module Hecks
34
36
  edge = options[:edge] || Edge.read(bluebook, table: table, vocabulary: Table.vocabulary(registry))
35
37
  return {} unless edge
36
38
 
37
- return { "behaviors" => behaviors(edge) } unless edge.alb?
39
+ regions(edge)
40
+ end
38
41
 
39
- { "behaviors" => behaviors(edge), "listener_rules" => listener_rules(edge) }
42
+ # @param edge [Edge] a checked edge
43
+ # @return [Hash{String => String}] the regions the edge needs
44
+ def regions(edge)
45
+ { "behaviors" => behaviors(edge) }
46
+ .merge(edge.alb? ? { "listener_rules" => listener_rules(edge) } : {})
47
+ .merge(edge.functions.any? ? { "edge_functions" => edge_functions(edge) } : {})
48
+ end
49
+
50
+ # @param edge [Edge] a checked edge
51
+ # @return [String] one function resource per `EdgeFunction` row, ending in a newline
52
+ def edge_functions(edge)
53
+ edge.functions.map { |function| Edge::FunctionCode.resource(function) }.join("\n")
40
54
  end
41
55
 
42
56
  # @param edge [Edge] a checked edge
@@ -32,6 +32,11 @@ module Hecks
32
32
  permissions:
33
33
  contents: read
34
34
 
35
+ # A newer push to the same PR supersedes the run in flight; pushes to main each keep their own run.
36
+ concurrency:
37
+ group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
38
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
39
+
35
40
  jobs:
36
41
  routes-current-and-parity:
37
42
  runs-on: ubuntu-24.04
@@ -11,7 +11,7 @@ module Hecks
11
11
  :indexable, :switch, :off, :preview, :label, :seo, :redirect_to, :aliases,
12
12
  :nav_group, :nav_order, :mobile_order, :footer_column, :footer_order,
13
13
  :admin_key, :admin_order, :compress, :alb_rule, :cdn,
14
- :seo_title, :edge_verbs, :mobile_heading, :explicit_cache, keyword_init: true)
14
+ :seo_title, :edge_verbs, :mobile_heading, :explicit_cache, :edge, keyword_init: true)
15
15
 
16
16
  # Turns a declared member into a `Row`: checks its fields and their types, fills the
17
17
  # defaults, and checks each closed-set value against the Site chapter's vocabulary.
@@ -23,7 +23,10 @@ module Hecks
23
23
  aliases: String, nav_group: String, nav_order: Integer, mobile_order: Integer,
24
24
  footer_column: String, footer_order: Integer, admin_key: String,
25
25
  admin_order: Integer, compress: :bool, alb_rule: String, cdn: :bool,
26
- seo_title: String, edge_methods: String, mobile_heading: String }.freeze
26
+ seo_title: String, edge_methods: String, mobile_heading: String, edge: String }.freeze
27
+
28
+ # What an `edge:` names: the logical id of an `EdgeFunction` row.
29
+ EDGE_NAME = /\A[A-Za-z][A-Za-z0-9]*\z/
27
30
 
28
31
  # The value objects of the Site chapter's `Route` aggregate whose members are the closed
29
32
  # sets, by the row field each constrains.
@@ -103,6 +106,13 @@ module Hecks
103
106
  problem(label, "has #{field} #{row[field].inspect}; #{field} is one of #{@vocabulary.fetch(field).join(", ")}")
104
107
  end
105
108
  check_verbs(row, label)
109
+ check_edge(row, label)
110
+ end
111
+
112
+ def check_edge(row, label)
113
+ return unless row.edge.is_a?(String) && !EDGE_NAME.match?(row.edge)
114
+
115
+ problem(label, "has edge #{row.edge.inspect}; an edge is the logical id of an EdgeFunction row")
106
116
  end
107
117
 
108
118
  def check_verbs(row, label)
@@ -60,7 +60,8 @@ module Hecks
60
60
 
61
61
  # @param registry [Runtime::Registry] a registry holding the Site chapter
62
62
  # @return [Hash{Symbol => Array<String>}] each constrained field to its allowed values, and
63
- # `:http_method` to the verbs a route may answer
63
+ # `:http_method` to the verbs a route may answer and `:edge_function_kind` to the kinds of
64
+ # `EdgeFunction` row
64
65
  # @raise [Invalid] when the registry does not hold the Site chapter
65
66
  def self.vocabulary(registry)
66
67
  site = registry.bluebook("Site") or
@@ -69,7 +70,8 @@ module Hecks
69
70
  members = lambda do |name|
70
71
  route.value_objects.find { |object| object.hecks_name == name }.members.map { |member| member.fetch(:value) }
71
72
  end
72
- RowBuilder::VOCABULARY.transform_values(&members).merge(http_method: members.call("HttpMethod"))
73
+ closed = RowBuilder::VOCABULARY.transform_values(&members)
74
+ closed.merge(http_method: members.call("HttpMethod"), edge_function_kind: members.call("EdgeFunctionKind"))
73
75
  end
74
76
 
75
77
  # @param chapter [Bluebook::Chapter] a chapter to search
@@ -13,6 +13,26 @@ module Hecks
13
13
  hidden.empty? ? specs : specs.reject { |_, spec| hidden.include?(spec[:group]) }
14
14
  end
15
15
 
16
+ # The specs the options' audience is shown: those not hidden, and only those listed, if any
17
+ # are.
18
+ def audience_specs(specs, options)
19
+ only_listed(without_hidden(specs, options[:hide]), options[:only])
20
+ end
21
+
22
+ # The specs a newcomer is shown: only those named, or all of them when none are.
23
+ def only_listed(specs, only)
24
+ listed = Array(only)
25
+ listed.empty? ? specs : specs.slice(*listed)
26
+ end
27
+
28
+ # The line saying the help lists only the first steps and how to list the rest, or no line
29
+ # when it lists everything.
30
+ def newcomer_hint(program, only)
31
+ return [] if Array(only).empty?
32
+
33
+ [" #{program} --all also list every command (no bluebook is in this folder yet)"]
34
+ end
35
+
16
36
  # The attached chapters the help points at, one line each, under the call that opens them.
17
37
  #
18
38
  # @param chapters [Array<Array(String, String)>, nil] each chapter's word and its summary
@@ -34,8 +34,7 @@ module Hecks
34
34
 
35
35
  # The whole help: the two tables, the chapters, then the hints.
36
36
  def full_usage(bluebook, commands, questions, options, program)
37
- shown = [CliAudience.without_hidden(commands, options[:hide]),
38
- CliAudience.without_hidden(questions, options[:hide])]
37
+ shown = [commands, questions].map { |specs| CliAudience.audience_specs(specs, options) }
39
38
  [usage_header(bluebook, program),
40
39
  tables(*shown, all: options[:all], notes: aggregate_notes(bluebook)),
41
40
  CliAudience.chapter_lines(program, options[:chapters]),
@@ -57,6 +56,11 @@ module Hecks
57
56
  # The pointers after the tables: `--help`, `--all`, `--maintainer` and an example call.
58
57
  def usage_footer(program, shown, options, hidden_count)
59
58
  out = ["", " #{program} <command> --help what one command wants, and every way it refuses"]
59
+ if Array(options[:only]).any?
60
+ out.concat(CliAudience.newcomer_hint(program, options[:only]))
61
+ return out << " start with #{program} init! — it writes the stub files of a new domain"
62
+ end
63
+
60
64
  out.concat(all_hint(program, *shown)) unless options[:all]
61
65
  out.concat(CliAudience.maintainer_hint(program, hidden_count))
62
66
  out << " a command is called with its aggregate — #{example_qualified(shown.first)}"
@@ -64,8 +68,15 @@ module Hecks
64
68
 
65
69
  # The commands table then the queries table.
66
70
  def tables(commands, questions, all:, notes:)
67
- ["commands:", *listing(commands, all: all, notes: notes) { |spec| spec[:summary] }, "",
68
- "queries (nothing here changes anything):",
71
+ ["commands:", *listing(commands, all: all, notes: notes) { |spec| spec[:summary] },
72
+ *queries_table(questions, all: all, notes: notes)]
73
+ end
74
+
75
+ # The queries table, or nothing when the audience is shown no query.
76
+ def queries_table(questions, all:, notes:)
77
+ return [] if questions.empty?
78
+
79
+ ["", "queries (nothing here changes anything):",
69
80
  *listing(questions, all: all, notes: notes) { |spec| first_sentence(spec[:summary]) }]
70
81
  end
71
82
 
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "open3"
5
5
  require_relative "../rust_build"
6
6
  require_relative "wasm"
7
+ require_relative "prebuilt_binary"
7
8
 
8
9
  module Hecks
9
10
  module RustBuild
@@ -102,13 +103,19 @@ module Hecks
102
103
  def compile(target)
103
104
  host_dir = File.join(RustBuild.rust_dir, "host")
104
105
  target_dir = ENV.fetch("CARGO_TARGET_DIR", File.join(host_dir, "target"))
106
+ binary = File.join(target_dir, target, "release", BINARY)
107
+ return binary if PrebuiltBinary.fetch("host", host_dir, target: target, into: binary)
108
+
109
+ cargo_build(target, host_dir, { "CARGO_TARGET_DIR" => target_dir }.merge(linker_for(target)))
110
+ binary
111
+ rescue Failure => e
112
+ raise Failure, "#{e.message}#{cross_hint(target)}"
113
+ end
114
+
115
+ def cargo_build(target, host_dir, env)
105
116
  puts "== cargo build --release --target #{target} --bin #{BINARY} (rust/host) =="
106
- env = { "CARGO_TARGET_DIR" => target_dir }.merge(linker_for(target))
107
117
  RustBuild.command!("rustup", "run", TOOLCHAIN, "cargo", "build", "--release", "--target", target,
108
118
  "--bin", BINARY, chdir: host_dir, env: env)
109
- File.join(target_dir, target, "release", BINARY)
110
- rescue Failure => e
111
- raise Failure, "#{e.message}#{cross_hint(target)}"
112
119
  end
113
120
 
114
121
  def linker_for(target)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "net/http"
6
+ require "uri"
7
+ require "hecks/vocabulary"
8
+
9
+ module Hecks
10
+ module RustBuild
11
+ # A binary hecks has already compiled and published, named by what went into it (ADR 0110).
12
+ #
13
+ # The `PrebuiltBinary` rows of the Vocabulary chapter say which binaries are published: the
14
+ # crate to key, the Cargo binary, the target it is built for. A release tagged `<name>-<key>`
15
+ # holds the binary and its `.sha256`; the key is a hash of the crate's build inputs, so the
16
+ # same bytes are the same release, whichever commit they come from. `fetch` downloads it and
17
+ # checks its hash, and answers nil on any miss (not published yet, no network, a hash that
18
+ # does not match) so a caller compiles as it always did. `HECKS_PREBUILT=off` turns the
19
+ # download off.
20
+ module PrebuiltBinary
21
+ # Where releases are published.
22
+ REPOSITORY = "heckslabs/hecks"
23
+
24
+ # Left out of the key: Cargo's own output, and files that are never part of the binary.
25
+ EXCLUDED = %w[target tests .gitignore].freeze
26
+
27
+ MAX_REDIRECTS = 5
28
+
29
+ module_function
30
+
31
+ # @param name [String] a `PrebuiltBinary` row's name
32
+ # @return [Hash{String => String}] the row
33
+ # @raise [KeyError] when no row has the name
34
+ def row(name)
35
+ Hecks::Vocabulary.rows("PrebuiltBinary").find { |found| found["name"] == name } or
36
+ raise KeyError, "no PrebuiltBinary row named #{name.inspect}"
37
+ end
38
+
39
+ # @param crate_dir [String] the crate's directory
40
+ # @return [String] a hash of every file that goes into the build: each path and its bytes, in
41
+ # path order, so it does not depend on the checkout it is read from
42
+ def key(crate_dir)
43
+ files = Dir.glob("**/*", File::FNM_DOTMATCH, base: crate_dir).sort.select do |path|
44
+ File.file?(File.join(crate_dir, path)) && !EXCLUDED.include?(path.split("/").first)
45
+ end
46
+ digest = Digest::SHA256.new
47
+ files.each { |path| digest << "#{path}\0#{File.binread(File.join(crate_dir, path))}\0" }
48
+ digest.hexdigest[0, 24]
49
+ end
50
+
51
+ # @param name [String] a row's name
52
+ # @param key [String] the crate's key
53
+ # @return [String] the release tag
54
+ def tag(name, key) = "#{name}-#{key}"
55
+
56
+ # @param name [String] a row's name
57
+ # @return [String] the file the release holds the binary as
58
+ def asset(name)
59
+ found = row(name)
60
+ "#{found["executable"]}-#{found["target"]}"
61
+ end
62
+
63
+ # @param name [String] a row's name
64
+ # @param crate_dir [String] the crate's directory
65
+ # @param target [String] the triple the caller builds for
66
+ # @param into [String] the file to write the binary to
67
+ # @return [String, nil] `into`, or nil when the binary is not published for this target or
68
+ # could not be fetched and verified
69
+ def fetch(name, crate_dir, target:, into:)
70
+ return if ENV["HECKS_PREBUILT"] == "off" || row(name)["target"] != target
71
+
72
+ body = verified(name, crate_dir) or return
73
+ FileUtils.mkdir_p(File.dirname(into))
74
+ File.binwrite(into, body)
75
+ File.chmod(0o755, into)
76
+ puts "== downloaded the prebuilt #{asset(name)} (#{tag(name, key(crate_dir))}); nothing compiled =="
77
+ into
78
+ rescue SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError
79
+ nil
80
+ end
81
+
82
+ # @return [String, nil] the published binary, nil unless its bytes hash to the published
83
+ # sha256
84
+ def verified(name, crate_dir)
85
+ base = "https://github.com/#{REPOSITORY}/releases/download/#{tag(name, key(crate_dir))}/#{asset(name)}"
86
+ expected = get("#{base}.sha256")&.split&.first
87
+ body = expected && get(base)
88
+ body if body && Digest::SHA256.hexdigest(body) == expected
89
+ end
90
+
91
+ # @return [String, nil] the body at the URL, following redirects, nil unless it answers 200
92
+ def get(url, redirects = MAX_REDIRECTS)
93
+ response = Net::HTTP.get_response(URI(url))
94
+ return response.body if response.is_a?(Net::HTTPSuccess)
95
+ return get(response["location"], redirects - 1) if response.is_a?(Net::HTTPRedirection) && redirects.positive?
96
+
97
+ nil
98
+ end
99
+ end
100
+ end
101
+ end
@@ -12,7 +12,9 @@
12
12
  # The same command projects the CDN: a project that adds Edge, EdgePolicy, EdgeOrigin and EdgeRule rows
13
13
  # to its chapter has the CloudFront behaviours and the listener rules written into the marked regions
14
14
  # of the template it owns (the `:site_cdn` projection). Those rows are the template's facts, so only the
15
- # closed sets they draw on, cache class and origin, are named here.
15
+ # closed sets they draw on, cache class, origin and the kind of an EdgeFunction row, are named here. A
16
+ # route's `edge:` names an EdgeFunction row, a viewer-request function the projection writes into the
17
+ # template's `edge_functions` region and attaches to that route's behaviour.
16
18
  #
17
19
  # Each run records that it was requested before the SiteToolchain port is asked, and what came of
18
20
  # it after. No command declares a role: the chapter boots standing alone.
@@ -33,6 +35,7 @@ Hecks.bluebook "Site" do
33
35
  attribute :source, Source, optional: true
34
36
  attribute :http_method, HttpMethod, optional: true
35
37
  attribute :preview, Preview, optional: true
38
+ attribute :edge, EdgeName, optional: true
36
39
 
37
40
  # `/blog/:slug.html` names a parameter; a trailing `/*` matches everything beneath a prefix. A
38
41
  # row with a `command:` or `query:` source may leave its path out, and the projection derives it
@@ -78,6 +81,18 @@ Hecks.bluebook "Site" do
78
81
  attribute :value, String, one_of: ["public", "draft"]
79
82
  end
80
83
 
84
+ # The logical id of an `EdgeFunction` row: the viewer-request function the route's behaviour runs.
85
+ value_object "EdgeName" do
86
+ attribute :value, String, pattern: '\A[A-Za-z][A-Za-z0-9]*\z'
87
+ end
88
+
89
+ # What an `EdgeFunction` row asks the projection to write: `redirect_host` answers a request for
90
+ # a watched path on an alias host with a redirect to the canonical host and leaves every other
91
+ # request alone.
92
+ value_object "EdgeFunctionKind" do
93
+ attribute :value, String, one_of: ["redirect_host"]
94
+ end
95
+
81
96
  # The HTTP verbs a route answers.
82
97
  value_object "HttpMethod" do
83
98
  attribute :value, String, one_of: ["GET", "POST", "PUT", "PATCH", "DELETE"]
@@ -5,6 +5,7 @@ require "open3"
5
5
  require "hecks/vocabulary"
6
6
  require_relative "../tools"
7
7
  require_relative "ci_gates"
8
+ require_relative "release_only"
8
9
 
9
10
  module Hecks
10
11
  module Tools
@@ -43,6 +44,8 @@ module Hecks
43
44
  # @param env [Hash{String => String}] the runner's environment
44
45
  # @return [Boolean] whether the gated job should run
45
46
  def touched?(gate, root:, env:)
47
+ return !inherits?(gate, root: root, env: env) if gate.fetch("mode") == "release_only"
48
+
46
49
  changed, reason = change_set(gate, root: root, env: env)
47
50
  if reason
48
51
  explain(gate, reason)
@@ -54,6 +57,38 @@ module Hecks
54
57
  gate.fetch("mode") == "touches" ? matches.any? : matches.any?(false)
55
58
  end
56
59
 
60
+ # Whether the change is only a release bump on a base whose required checks all passed, so
61
+ # the run may inherit the base's certification (`ReleaseOnly`). Anything unconfirmed answers
62
+ # false and the suite runs.
63
+ #
64
+ # @param gate [Hash{String => String}] the `CiGate` row
65
+ # @return [Boolean]
66
+ def inherits?(gate, root:, env:)
67
+ base = base_of(gate, root: root, env: env)
68
+ reason = inheritance_refusal(base, root: root, env: env)
69
+ warn "decide_ci_gate: #{reason}: running the suite rather than inheriting certification" if reason
70
+ reason.nil?
71
+ end
72
+
73
+ # @return [String, nil] why the change cannot inherit, nil when it can
74
+ def inheritance_refusal(base, root:, env:)
75
+ return "no usable base to diff against" if base.nil? || base.empty? || base == NO_COMMIT
76
+
77
+ changes = ReleaseOnly.changes(base, env.fetch("GITHUB_SHA", "HEAD"), root: root)
78
+ return "git diff itself failed" if changes.nil?
79
+ return "the change is not only release files and lines" unless ReleaseOnly.release_only?(changes)
80
+
81
+ base_refusal(base, env)
82
+ end
83
+
84
+ # @return [String, nil] why the base is not certified, nil when it is
85
+ def base_refusal(base, env)
86
+ runs = ReleaseOnly.check_runs(env.fetch("GITHUB_REPOSITORY", ""), base, env: env)
87
+ return "the base's check runs could not be read" if runs.nil?
88
+
89
+ "the base is not fully certified" unless ReleaseOnly.certified?(runs)
90
+ end
91
+
57
92
  # @return [Array(Array<String>, nil), Array(nil, String)] the changed paths, or the reason the
58
93
  # change cannot be confirmed
59
94
  def change_set(gate, root:, env:)
@@ -46,7 +46,7 @@ module Hecks
46
46
 
47
47
  # The values a row's `mode` and `push` may take: the language holds one closed set to a value
48
48
  # object, so the rest are held here.
49
- MODES = %w[touches skips_unless].freeze
49
+ MODES = %w[touches skips_unless release_only].freeze
50
50
  PUSHES = %w[skip before_sha].freeze
51
51
 
52
52
  # @return [Array<Hash{String => String}>] the `CiGate` rows
@@ -111,16 +111,35 @@ module Hecks
111
111
  " #{name}:",
112
112
  " runs-on: ubuntu-latest",
113
113
  " timeout-minutes: 10",
114
+ *job_permissions(gate),
114
115
  *job_condition(gate)]
115
116
  end
116
117
 
117
118
  # @return [Array<String>] the deciding step and the closing marker
118
119
  def job_footer(gate, name)
119
120
  [" name: #{quoted(gate.fetch("label"))}",
121
+ *job_env(gate),
120
122
  " run: bundle exec exe/hecks regeneration_run.decide_ci_gate gate=#{name} --wait",
121
123
  " # END GENERATED ci_gate #{name}"]
122
124
  end
123
125
 
126
+ # A `release_only` gate reads the base commit's check runs through the API, so its job may
127
+ # read checks and nothing more.
128
+ #
129
+ # @return [Array<String>] the job's `permissions:` lines, none for any other mode
130
+ def job_permissions(gate)
131
+ return [] unless gate.fetch("mode") == "release_only"
132
+
133
+ [" permissions:", " contents: read", " checks: read"]
134
+ end
135
+
136
+ # @return [Array<String>] the deciding step's `env:` lines, none unless the gate calls the API
137
+ def job_env(gate)
138
+ return [] unless gate.fetch("mode") == "release_only"
139
+
140
+ [" env:", " GH_TOKEN: ${{ github.token }}"]
141
+ end
142
+
124
143
  # @param gate [Hash{String => String}] a `CiGate` row
125
144
  # @return [Array<String>] the job-level `if:` lines for a gate that skips push runs
126
145
  def job_condition(gate)
@@ -18,6 +18,7 @@ module Hecks
18
18
  def projection
19
19
  rulesets.merge(workflow(Lanes::WORKFLOW, "follows") { |lanes| Lanes.workflow(lanes) })
20
20
  .merge(workflow(Lanes::WATCH_WORKFLOW, "alert_after") { |lanes| WatchWorkflow.text(lanes) })
21
+ .merge(prebuilt)
21
22
  end
22
23
 
23
24
  # @param files [Hash{String => String}] the projection
@@ -44,6 +45,13 @@ module Hecks
44
45
  Lanes.rulesets.to_h { |name, ruleset| [ruleset_path(name), "#{JSON.pretty_generate(ruleset)}\n"] }
45
46
  end
46
47
 
48
+ # The workflow that publishes each `PrebuiltBinary` row.
49
+ def prebuilt
50
+ Hecks::Vocabulary.rows("PrebuiltBinary").to_h do |row|
51
+ [File.join(@root, PrebuiltWorkflow.path(row)), PrebuiltWorkflow.text(row)]
52
+ end
53
+ end
54
+
47
55
  # The workflow at `path` for the lanes whose `column` is set, or no file if none is.
48
56
  def workflow(path, column)
49
57
  lanes = Lanes.lanes.reject { |lane| lane[column].to_s.empty? }