hecks 3.11.0 → 3.12.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  3. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  4. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  5. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  6. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  7. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  8. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  9. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  10. data/lib/hecks/bluebook.rb +1 -0
  11. data/lib/hecks/deploy/bluebook/deploy.bluebook +16 -2
  12. data/lib/hecks/gate/stages.yml +32 -0
  13. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  14. data/lib/hecks/hecks/hecks.world +3 -0
  15. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  16. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  18. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  19. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  20. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  22. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  23. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  24. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  25. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  26. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  27. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  28. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  29. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  30. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  31. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  32. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  33. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  34. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  35. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  36. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  37. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  38. data/lib/hecks/projections/deploy/box.rb +15 -3
  39. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  40. data/lib/hecks/projector/cli_audience.rb +20 -0
  41. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  42. data/lib/hecks/rust_build/host.rb +11 -4
  43. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  44. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  45. data/lib/hecks/tools/ci_gates.rb +20 -1
  46. data/lib/hecks/tools/lanes/files.rb +8 -0
  47. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  48. data/lib/hecks/tools/lanes.rb +1 -0
  49. data/lib/hecks/tools/release_only.rb +111 -0
  50. data/lib/hecks/version.rb +1 -1
  51. data/lib/hecks/vocabulary.rb +15 -1
  52. data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
  53. data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
  54. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json} +1 -1
  55. data/rust/host/HECKS_RELEASE +1 -1
  56. data/rust/host/src/extension.rs +13 -1
  57. data/rust/host/src/lib.rs +1 -0
  58. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  59. data/rust/host/src/platform_signin/config.rs +512 -0
  60. data/rust/host/src/platform_signin/cookie.rs +237 -0
  61. data/rust/host/src/platform_signin/extension.rs +190 -0
  62. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  63. data/rust/host/src/platform_signin/handler.rs +251 -0
  64. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  65. data/rust/host/src/platform_signin/jti.rs +196 -0
  66. data/rust/host/src/platform_signin/jwks.rs +542 -0
  67. data/rust/host/src/platform_signin/jws.rs +249 -0
  68. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  69. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  70. data/rust/host/src/platform_signin/mod.rs +75 -0
  71. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  72. data/rust/host/src/platform_signin/pages.rs +204 -0
  73. data/rust/host/src/platform_signin/query.rs +233 -0
  74. data/rust/host/src/platform_signin/session.rs +151 -0
  75. data/rust/host/src/platform_signin/settings.rs +385 -0
  76. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  77. data/rust/host/src/platform_signin/test_support.rs +235 -0
  78. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  79. data/rust/host/src/platform_signin/verify.rs +604 -0
  80. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  81. data/rust/host/src/web.rs +50 -6
  82. data/rust/parser/src/parse/value_object.rs +22 -10
  83. metadata +42 -4
  84. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  85. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
@@ -5,10 +5,12 @@ require_relative "box/hosting"
5
5
  require_relative "box/roll_recipe"
6
6
  require_relative "box/caddy"
7
7
  require_relative "box/access"
8
+ require_relative "box/logging"
8
9
  require_relative "box/stack_yaml"
9
10
  require_relative "box/compose"
10
11
  require_relative "box/shared_database"
11
12
  require_relative "box/roll_scripts"
13
+ require_relative "box/edge"
12
14
 
13
15
  module Hecks
14
16
  module Projections
@@ -21,10 +23,12 @@ module Hecks
21
23
  extend Projector::Target
22
24
  extend Caddy
23
25
  extend Access
26
+ extend Logging
24
27
  extend StackYaml
25
28
  extend Compose
26
29
  extend SharedDatabase
27
30
  extend RollScripts
31
+ extend Edge
28
32
 
29
33
  projects_as :aws_box, needs_world: true, emits: :files
30
34
 
@@ -123,7 +127,15 @@ module Hecks
123
127
  volume_gb: { value: settings.fetch(:volume_gb, 30) },
124
128
  database_class: { value: settings.fetch(:database_class, "db.t4g.small") },
125
129
  storage_gb: { value: settings.fetch(:storage_gb, 20) }
126
- }
130
+ }.merge(cdn_arguments(settings[:cdn]))
131
+ end
132
+
133
+ # @param cdn [Hash{Symbol => Object}, nil] the world's `cdn` setting
134
+ # @return [Hash{Symbol => Array}] the `cdn_aliases` argument, or nothing without a `cdn`
135
+ def cdn_arguments(cdn)
136
+ return {} unless cdn.is_a?(Hash) && cdn[:aliases].is_a?(Array)
137
+
138
+ { cdn_aliases: cdn[:aliases].map { |name| { value: name } } }
127
139
  end
128
140
 
129
141
  # @param plan [Settings::Plan] the resolved settings
@@ -136,8 +148,8 @@ module Hecks
136
148
  "render-compose.sh" => render_compose_sh(plan, region),
137
149
  "fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
138
150
  "deploy-box.sh" => deploy_box_sh(plan, region),
139
- "Makefile" => makefile(plan)
140
- }.merge(database_files(plan)).merge(migration_files(plan))
151
+ "Makefile" => makefile(plan, region)
152
+ }.merge(database_files(plan)).merge(migration_files(plan)).merge(edge_files(plan, region))
141
153
  .then { |files| Hosting.extend_files(files, plan: plan, region: region) }
142
154
  end
143
155
 
@@ -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
@@ -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
@@ -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? }
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Tools
5
+ module Lanes
6
+ # The workflow that builds and publishes one `PrebuiltBinary` row (ADR 0110): after a push to
7
+ # `main` that touches the row's crate, it builds the binary on the row's runner and, unless
8
+ # a release for that key already exists, publishes it with its sha256. What the key is and
9
+ # where a client looks is `Hecks::RustBuild::PrebuiltBinary`, not anything written here.
10
+ module PrebuiltWorkflow
11
+ module_function
12
+
13
+ # @param row [Hash{String => String}] a `PrebuiltBinary` row
14
+ # @return [String] the path of the workflow, relative to the checkout
15
+ def path(row) = ".github/workflows/publish-#{row["name"]}.yml"
16
+
17
+ # @param row [Hash{String => String}] a `PrebuiltBinary` row
18
+ # @return [String] the workflow
19
+ def text(row)
20
+ [*header(row), *trigger(row), *job(row)].join("\n") << "\n"
21
+ end
22
+
23
+ def header(row)
24
+ name = row["name"]
25
+ ["name: Publish #{name}", "",
26
+ "# Generated by hecks project_lanes from the PrebuiltBinary rows of the Vocabulary chapter. Do not hand-edit.",
27
+ "#",
28
+ "# Builds #{row["executable"]} from #{row["crate"]} for #{row["target"]} and publishes it as the release",
29
+ "# #{name}-<key>, where the key is a hash of the crate's build inputs. A client that needs the same",
30
+ "# bytes downloads it instead of compiling (Hecks::RustBuild::PrebuiltBinary); a key that already has a",
31
+ "# release is left alone, so a push that changed nothing the build reads publishes nothing."]
32
+ end
33
+
34
+ def trigger(row)
35
+ ["on:", " push:", " branches: [main]", " paths:", " - \"#{row["crate"]}/**\"",
36
+ " - \".github/workflows/publish-#{row["name"]}.yml\"",
37
+ " - \"lib/hecks/rust_build/prebuilt_binary.rb\"",
38
+ " workflow_dispatch: {}", "",
39
+ "concurrency:", " group: publish-#{row["name"]}", " cancel-in-progress: false", "",
40
+ "permissions:", " contents: write", ""]
41
+ end
42
+
43
+ def job(row)
44
+ ["jobs:", " publish:", " runs-on: #{row["runner"]}", " timeout-minutes: 40", " steps:",
45
+ " - uses: actions/checkout@v4", " - uses: ./.github/actions/setup-ruby", *key_step(row),
46
+ *build_steps(row), *release_step(row)]
47
+ end
48
+
49
+ def key_step(row)
50
+ [" - name: Name the release", " id: key", " run: |",
51
+ " key=$(bundle exec ruby -Ilib -rhecks/rust_build/prebuilt_binary -e \\",
52
+ " 'puts Hecks::RustBuild::PrebuiltBinary.key(ARGV.fetch(0))' #{row["crate"]})",
53
+ " tag=#{row["name"]}-$key",
54
+ " echo \"tag=$tag\" >> \"$GITHUB_OUTPUT\"",
55
+ " if gh release view \"$tag\" > /dev/null 2>&1; then echo \"exists=true\" >> \"$GITHUB_OUTPUT\"; fi",
56
+ " env:", " GH_TOKEN: ${{ github.token }}"]
57
+ end
58
+
59
+ def build_steps(row)
60
+ [" - name: Build #{row["executable"]}", " if: steps.key.outputs.exists != 'true'",
61
+ " working-directory: #{row["crate"]}", " run: |",
62
+ " rustup toolchain install stable --profile minimal",
63
+ " rustup run stable cargo build --release --target #{row["target"]} --bin #{row["executable"]}"]
64
+ end
65
+
66
+ def release_step(row)
67
+ asset = "#{row["executable"]}-#{row["target"]}"
68
+ [" - name: Publish the release", " if: steps.key.outputs.exists != 'true'",
69
+ " env:", " GH_TOKEN: ${{ github.token }}", " TAG: ${{ steps.key.outputs.tag }}",
70
+ " run: |",
71
+ " mkdir out",
72
+ " cp #{row["crate"]}/target/#{row["target"]}/release/#{row["executable"]} out/#{asset}",
73
+ " (cd out && sha256sum #{asset} > #{asset}.sha256)",
74
+ " gh release create \"$TAG\" out/#{asset} out/#{asset}.sha256 --prerelease --target \"$GITHUB_SHA\" \\",
75
+ " --title \"$TAG\" \\",
76
+ " --notes \"#{row["executable"]} for #{row["target"]}, built from #{row["crate"]} at $GITHUB_SHA.\""]
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -78,5 +78,6 @@ require_relative "lanes/rows"
78
78
  require_relative "lanes/ruleset"
79
79
  require_relative "lanes/workflow"
80
80
  require_relative "lanes/watch_workflow"
81
+ require_relative "lanes/prebuilt_workflow"
81
82
  require_relative "lanes/files"
82
83
  require_relative "lanes/live"
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "hecks/vocabulary"
6
+ require_relative "../tools"
7
+
8
+ module Hecks
9
+ module Tools
10
+ # Decides whether a commit is only a release bump on a base that is fully certified, so CI may
11
+ # inherit the base's certification instead of running the suite again.
12
+ #
13
+ # The files a bump edits, and which of their lines it may change, are the `ReleaseFile` rows of
14
+ # the Vocabulary chapter. A change is a release bump when it touches at least one file, every
15
+ # file it touches is a row, and every line it adds or removes in a file matches that row's
16
+ # `lines` pattern (an empty pattern admits any line). Whether the base is certified is read
17
+ # from the base commit's own check runs: every `RequiredCheck` row that is not marked
18
+ # `runs: always` must have its latest run on the base succeed or be skipped.
19
+ #
20
+ # Every case that cannot be confirmed answers "not a release bump", so the suite runs.
21
+ module ReleaseOnly
22
+ GREEN = %w[success skipped].freeze
23
+
24
+ module_function
25
+
26
+ # @param changes [Hash{String => Array<String>}] each changed path to the lines it adds or
27
+ # removes
28
+ # @param files [Array<Hash{String => String}>] the `ReleaseFile` rows
29
+ # @return [Boolean] whether the change touches only release files, and only their release
30
+ # lines
31
+ def release_only?(changes, files: Hecks::Vocabulary.rows("ReleaseFile"))
32
+ return false if changes.empty?
33
+
34
+ by_path = files.to_h { |row| [row.fetch("path"), row.fetch("lines", "")] }
35
+ changes.all? { |path, lines| by_path.key?(path) && lines_allowed?(lines, by_path.fetch(path)) }
36
+ end
37
+
38
+ # @param lines [Array<String>] the changed lines of one file
39
+ # @param pattern [String] the row's `lines` regex, empty for any line
40
+ # @return [Boolean] whether every changed line matches
41
+ def lines_allowed?(lines, pattern)
42
+ return true if pattern.to_s.empty?
43
+
44
+ regex = Regexp.new(pattern)
45
+ lines.all? { |line| line.match?(regex) }
46
+ end
47
+
48
+ # @param runs [Array<Hash{String => String}>] the base commit's check runs: `name`,
49
+ # `conclusion` and `started_at`
50
+ # @param required [Array<Hash{String => String}>] the `RequiredCheck` rows
51
+ # @return [Boolean] whether every check that must be inherited has a green latest run
52
+ def certified?(runs, required: Hecks::Vocabulary.rows("RequiredCheck"))
53
+ latest = latest_runs(runs)
54
+ inherited = required.reject { |row| row["runs"] == "always" }.map { |row| row.fetch("name") }
55
+ inherited.all? { |name| GREEN.include?(latest[name].to_h["conclusion"]) }
56
+ end
57
+
58
+ # @return [Hash{String => Hash}] each check's most recently started run
59
+ def latest_runs(runs)
60
+ runs.group_by { |run| run["name"] }.transform_values { |group| group.max_by { |run| run["started_at"].to_s } }
61
+ end
62
+
63
+ # @param base [String] the commit the change is measured against
64
+ # @param head [String] the commit under test
65
+ # @param root [String] the checkout
66
+ # @return [Hash{String => Array<String>}, nil] each changed path to its changed lines, or nil
67
+ # when git could not say
68
+ def changes(base, head, root:)
69
+ out, _err, status = Open3.capture3("git", "-C", root, "diff", "-U0", "--no-color", base, head)
70
+ status.success? ? parse(out) : nil
71
+ end
72
+
73
+ # @param diff [String] `git diff -U0` output
74
+ # @return [Hash{String => Array<String>}] each changed path to the lines it adds or removes
75
+ def parse(diff)
76
+ found = Hash.new { |hash, key| hash[key] = [] }
77
+ path = nil
78
+ diff.each_line do |line|
79
+ path = file_of(line.chomp) || path
80
+ found[path] << line[1..].chomp if path && content_line?(line)
81
+ end
82
+ found.to_h
83
+ end
84
+
85
+ # @return [String, nil] the path a `+++` or `---` header names, nil for any other line
86
+ def file_of(line)
87
+ return line.delete_prefix("+++ b/") if line.start_with?("+++ b/")
88
+
89
+ line.delete_prefix("--- a/") if line.start_with?("--- a/")
90
+ end
91
+
92
+ # @return [Boolean] whether the line is an added or removed line, not a header
93
+ def content_line?(line)
94
+ line.match?(/\A[+-]/) && !line.start_with?("+++ ", "--- ")
95
+ end
96
+
97
+ # @param repository [String] `owner/name`
98
+ # @param sha [String] the commit whose check runs are read
99
+ # @param env [Hash{String => String}] carries `GH_TOKEN` for the API call
100
+ # @return [Array<Hash{String => String}>, nil] the check runs, or nil when the API failed
101
+ def check_runs(repository, sha, env: ENV)
102
+ path = "repos/#{repository}/commits/#{sha}/check-runs?per_page=100"
103
+ jq = ".check_runs[] | {name, conclusion, started_at}"
104
+ out, _err, status = Open3.capture3(env.to_h.slice("GH_TOKEN"), "gh", "api", path, "--paginate", "--jq", jq)
105
+ status.success? ? out.lines.map { |line| JSON.parse(line) } : nil
106
+ rescue JSON::ParserError
107
+ nil
108
+ end
109
+ end
110
+ end
111
+ end
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.11.0".freeze
4
+ VERSION = "3.12.0".freeze
5
5
  end
@@ -51,6 +51,7 @@ module Hecks
51
51
  ].freeze,
52
52
  "CiGate" => [
53
53
  {"name"=>"runtime_changed", "workflow"=>"ci.yml", "mode"=>"touches", "pattern"=>"^lib/hecks/runtime/", "push"=>"before_sha", "label"=>"does this change touch lib/hecks/runtime/**?"}.freeze,
54
+ {"name"=>"release_only", "workflow"=>"ci.yml", "mode"=>"release_only", "pattern"=>"", "push"=>"before_sha", "label"=>"is this change only a release bump on a certified base?"}.freeze,
54
55
  {"name"=>"postgres_io_relevant_changed", "workflow"=>"ci-postgres-io-parallel.yml", "mode"=>"skips_unless", "pattern"=>"^(docs/|editors/|release/|deploy/|\\.claude/|\\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests)/|\\.rubocop\\.yml$|\\.rubocop_todo\\.yml$|\\.mcp\\.json$|\\.rspec-local\\.example$|README\\.md$|CHANGELOG\\.md$|CONTRIBUTING\\.md$|SECURITY\\.md$|LICENSE$|\\.gitignore$)", "push"=>"skip", "label"=>"does this change touch anything rspec_postgres_io_parallel covers?"}.freeze
55
56
  ].freeze,
56
57
  "Comparison" => [
@@ -134,6 +135,9 @@ module Hecks
134
135
  {"name"=>"replace"}.freeze,
135
136
  {"name"=>"scale_call"}.freeze
136
137
  ].freeze,
138
+ "PrebuiltBinary" => [
139
+ {"name"=>"host", "crate"=>"rust/host", "executable"=>"bootstrap", "target"=>"x86_64-unknown-linux-gnu", "runner"=>"ubuntu-24.04"}.freeze
140
+ ].freeze,
137
141
  "Primitive" => [
138
142
  {"name"=>"String"}.freeze,
139
143
  {"name"=>"Integer"}.freeze,
@@ -363,6 +367,15 @@ module Hecks
363
367
  {"refusal"=>"AttributeAbsent", "site"=>"absent_read", "template"=>"{aggregate} {field} is absent on this record — declared, not optional, and added since it was written. Backfill it in a translation (backfill :{field}, default: ...), or declare it optional: true"}.freeze,
364
368
  {"refusal"=>"ProjectionAbsent", "site"=>"absent_read", "template"=>"{aggregate} {field} is not yet projected on this record — declared via projects :{field}, but no rebuild sweep has populated it. Run the sweep, or read {reference}.{remote_field} directly if this rule cannot wait"}.freeze
365
369
  ].freeze,
370
+ "ReleaseFile" => [
371
+ {"path"=>"lib/hecks/version.rb", "lines"=>"^\\s*VERSION = \"[0-9]+\\.[0-9]+\\.[0-9]+\"\\.freeze$"}.freeze,
372
+ {"path"=>"CHANGELOG.md", "lines"=>""}.freeze,
373
+ {"path"=>"README.md", "lines"=>"^(\\*\\*Status:\\*\\* )?Current release: `[0-9]+\\.[0-9]+\\.[0-9]+`"}.freeze,
374
+ {"path"=>"Gemfile.lock", "lines"=>"^ hecks \\([0-9]+\\.[0-9]+\\.[0-9]+\\)$"}.freeze,
375
+ {"path"=>"rust/host/HECKS_RELEASE", "lines"=>"^[0-9]+\\.[0-9]+\\.[0-9]+$"}.freeze,
376
+ {"path"=>"packages/hecks-client/package.json", "lines"=>"^\\s*\"version\": \"[0-9]+\\.[0-9]+\\.[0-9]+\",?$"}.freeze,
377
+ {"path"=>"packages/hecks-client/package-lock.json", "lines"=>"^\\s*\"version\": \"[0-9]+\\.[0-9]+\\.[0-9]+\",?$"}.freeze
378
+ ].freeze,
366
379
  "RequiredCheck" => [
367
380
  {"name"=>"rspec"}.freeze,
368
381
  {"name"=>"checks"}.freeze,
@@ -372,7 +385,8 @@ module Hecks
372
385
  {"name"=>"rspec_rust_parser"}.freeze,
373
386
  {"name"=>"rspec_rust_codegen"}.freeze,
374
387
  {"name"=>"rspec_rust_host"}.freeze,
375
- {"name"=>"rspec_fuzzing"}.freeze
388
+ {"name"=>"rspec_fuzzing"}.freeze,
389
+ {"name"=>"release_consistency", "runs"=>"always"}.freeze
376
390
  ].freeze,
377
391
  "RetiredScript" => [
378
392
  {"script"=>"ir", "section"=>"Custodian", "aggregate"=>"Introspection", "verb"=>"ir"}.freeze,