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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
- data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
- data/lib/hecks/adapters/driving/newcomer.rb +50 -0
- data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -15
- data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
- data/lib/hecks/bluebook.rb +1 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +16 -2
- data/lib/hecks/gate/stages.yml +32 -0
- data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
- data/lib/hecks/hecks/hecks.world +3 -0
- data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
- data/lib/hecks/language/bluebook/policy.behaviors +19 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
- data/lib/hecks/projection_files/expression_tables.rb +7 -2
- data/lib/hecks/projections/deploy/box/compose.rb +6 -4
- data/lib/hecks/projections/deploy/box/edge.rb +62 -0
- data/lib/hecks/projections/deploy/box/logging.rb +99 -0
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
- data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
- data/lib/hecks/projections/deploy/box/settings.rb +6 -2
- data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
- data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
- data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
- data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box.rb +15 -3
- data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
- data/lib/hecks/projector/cli_audience.rb +20 -0
- data/lib/hecks/projector/cli_projector/usage.rb +15 -4
- data/lib/hecks/rust_build/host.rb +11 -4
- data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
- data/lib/hecks/tools/ci_gate_decision.rb +35 -0
- data/lib/hecks/tools/ci_gates.rb +20 -1
- data/lib/hecks/tools/lanes/files.rb +8 -0
- data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
- data/lib/hecks/tools/lanes.rb +1 -0
- data/lib/hecks/tools/release_only.rb +111 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +15 -1
- data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
- data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
- data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json} +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/extension.rs +13 -1
- data/rust/host/src/lib.rs +1 -0
- data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
- data/rust/host/src/platform_signin/config.rs +512 -0
- data/rust/host/src/platform_signin/cookie.rs +237 -0
- data/rust/host/src/platform_signin/extension.rs +190 -0
- data/rust/host/src/platform_signin/fetcher.rs +322 -0
- data/rust/host/src/platform_signin/handler.rs +251 -0
- data/rust/host/src/platform_signin/handler_tests.rs +554 -0
- data/rust/host/src/platform_signin/jti.rs +196 -0
- data/rust/host/src/platform_signin/jwks.rs +542 -0
- data/rust/host/src/platform_signin/jws.rs +249 -0
- data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
- data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
- data/rust/host/src/platform_signin/mod.rs +75 -0
- data/rust/host/src/platform_signin/mode_tests.rs +382 -0
- data/rust/host/src/platform_signin/pages.rs +204 -0
- data/rust/host/src/platform_signin/query.rs +233 -0
- data/rust/host/src/platform_signin/session.rs +151 -0
- data/rust/host/src/platform_signin/settings.rs +385 -0
- data/rust/host/src/platform_signin/strict_json.rs +351 -0
- data/rust/host/src/platform_signin/test_support.rs +235 -0
- data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
- data/rust/host/src/platform_signin/verify.rs +604 -0
- data/rust/host/src/platform_signin/wiring_support.rs +319 -0
- data/rust/host/src/web.rs +50 -6
- data/rust/parser/src/parse/value_object.rs +22 -10
- metadata +42 -4
- data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
- 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.
|
|
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
|
-
|
|
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:)
|
data/lib/hecks/tools/ci_gates.rb
CHANGED
|
@@ -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
|
data/lib/hecks/tools/lanes.rb
CHANGED
|
@@ -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
data/lib/hecks/vocabulary.rb
CHANGED
|
@@ -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,
|
|
Binary file
|