forge-ci 0.1.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 +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +273 -0
- data/exe/forge-ci +8 -0
- data/lib/forge_ci/cli.rb +203 -0
- data/lib/forge_ci/compiler.rb +537 -0
- data/lib/forge_ci/dag.rb +68 -0
- data/lib/forge_ci/errors.rb +101 -0
- data/lib/forge_ci/interpolator.rb +137 -0
- data/lib/forge_ci/ir.rb +117 -0
- data/lib/forge_ci/validator.rb +403 -0
- data/lib/forge_ci.rb +20 -0
- metadata +54 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForgeCI
|
|
4
|
+
# The config compiler: YAML text in, normalized IR out.
|
|
5
|
+
#
|
|
6
|
+
# ir, errors = ForgeCI::Compiler.compile(yaml_text, pipeline_params: {...})
|
|
7
|
+
# errors = ForgeCI::Compiler.validate(yaml_text)
|
|
8
|
+
#
|
|
9
|
+
# pipeline_params (all optional, string or symbol keys):
|
|
10
|
+
# "branch" -> resolves << pipeline.git.branch >> and branch filters
|
|
11
|
+
# "number" -> resolves << pipeline.number >>
|
|
12
|
+
# "parameters" -> Hash overriding top-level `parameters:` defaults
|
|
13
|
+
#
|
|
14
|
+
# compile returns [ir, errors]. errors is empty on success; ir is nil when
|
|
15
|
+
# errors is non-empty. ForgeCI::Error is raised only when the YAML text
|
|
16
|
+
# cannot be parsed at all.
|
|
17
|
+
class Compiler
|
|
18
|
+
def self.compile(yaml_text, pipeline_params: {})
|
|
19
|
+
new.compile(yaml_text, pipeline_params: pipeline_params)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.validate(yaml_text)
|
|
23
|
+
new.validate(yaml_text)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def compile(yaml_text, pipeline_params: {})
|
|
27
|
+
pp = normalize_pipeline_params(pipeline_params)
|
|
28
|
+
doc = parse_document(yaml_text)
|
|
29
|
+
errors = ErrorCollector.new(LineIndex.new(doc))
|
|
30
|
+
warnings = []
|
|
31
|
+
|
|
32
|
+
config = doc.to_ruby
|
|
33
|
+
unless config.is_a?(Hash)
|
|
34
|
+
errors.add([], "config root must be a mapping of top-level keys (got #{ruby_type(config)})")
|
|
35
|
+
return [nil, errors.to_a]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Validator.top_level(config, errors)
|
|
39
|
+
Validator.parameters(config["parameters"], errors)
|
|
40
|
+
each_mapping(config, "commands") { |n, d| Validator.command(n, d, errors) }
|
|
41
|
+
each_mapping(config, "executors") { |n, d| Validator.executor_definition(n, d, errors) }
|
|
42
|
+
return [nil, errors.to_a] if errors.any?
|
|
43
|
+
|
|
44
|
+
prenormalize_all_steps(config)
|
|
45
|
+
expand_commands(config, errors)
|
|
46
|
+
expand_executors(config, errors)
|
|
47
|
+
return [nil, errors.to_a] if errors.any?
|
|
48
|
+
|
|
49
|
+
param_values = resolve_parameters(config["parameters"], pp["parameters"], errors, warnings)
|
|
50
|
+
ctx = {
|
|
51
|
+
"parameters" => param_values,
|
|
52
|
+
"pipeline" => { "branch" => pp["branch"], "number" => pp["number"] },
|
|
53
|
+
"matrix" => {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Pipeline values that were not supplied (e.g. no branch) are left
|
|
57
|
+
# unresolved with a warning instead of failing the compile — the
|
|
58
|
+
# runner resolves them at execution time.
|
|
59
|
+
on_missing = lambda do |ref, path|
|
|
60
|
+
hint = ref == "pipeline.git.branch" ? "\"branch\"" : "\"number\""
|
|
61
|
+
warnings << "#{path.join('.')}: no value supplied for << #{ref} >>; " \
|
|
62
|
+
"pass #{hint} in pipeline_params to resolve it"
|
|
63
|
+
"<< #{ref} >>"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Global interpolation. Matrix references are intentionally left alone
|
|
67
|
+
# here; they resolve per matrix job during the workflow pass.
|
|
68
|
+
config["jobs"] = Interpolator.interpolate(config["jobs"], ctx, errors, ["jobs"],
|
|
69
|
+
skip_prefixes: ["matrix."], on_missing: on_missing)
|
|
70
|
+
config["workflows"] = Interpolator.interpolate(config["workflows"], ctx, errors, ["workflows"],
|
|
71
|
+
skip_prefixes: ["matrix."], on_missing: on_missing)
|
|
72
|
+
return [nil, errors.to_a] if errors.any?
|
|
73
|
+
|
|
74
|
+
known_jobs = Array(config["jobs"]).to_h.keys.map(&:to_s)
|
|
75
|
+
Array(config["jobs"]).to_h.each { |n, j| Validator.job(n.to_s, j, errors) }
|
|
76
|
+
Array(config["workflows"]).to_h.each { |n, w| Validator.workflow(n.to_s, w, errors, known_jobs) }
|
|
77
|
+
return [nil, errors.to_a] if errors.any?
|
|
78
|
+
|
|
79
|
+
workflows_ir = build_workflows(config, errors, ctx, warnings, on_missing)
|
|
80
|
+
return [nil, errors.to_a] if errors.any?
|
|
81
|
+
|
|
82
|
+
[IR.document(config["version"], workflows_ir, warnings), []]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate(yaml_text)
|
|
86
|
+
_ir, errors = compile(yaml_text, pipeline_params: {})
|
|
87
|
+
errors
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def normalize_pipeline_params(pp)
|
|
93
|
+
pp = (pp || {}).transform_keys(&:to_s)
|
|
94
|
+
{
|
|
95
|
+
"branch" => pp["branch"],
|
|
96
|
+
"number" => pp["number"],
|
|
97
|
+
"parameters" => (pp["parameters"] || {}).transform_keys(&:to_s)
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_document(yaml_text)
|
|
102
|
+
Psych.parse(yaml_text)
|
|
103
|
+
rescue Psych::SyntaxError => e
|
|
104
|
+
raise Error, "could not parse YAML: #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ruby_type(obj)
|
|
108
|
+
obj.nil? ? "nothing (empty file?)" : obj.class.name
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def each_mapping(config, key)
|
|
112
|
+
value = config[key]
|
|
113
|
+
return if value.nil?
|
|
114
|
+
return unless value.is_a?(Hash) # type error already reported by top_level
|
|
115
|
+
|
|
116
|
+
value.each { |name, definition| yield(name.to_s, definition) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# --- step pre-normalization ------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# Accepts the common shorthand forms before validation:
|
|
122
|
+
# - run: echo hi -> - run: {command: "echo hi"}
|
|
123
|
+
# - checkout: -> - checkout: {}
|
|
124
|
+
def prenormalize_all_steps(config)
|
|
125
|
+
each_mapping(config, "commands") do |_name, definition|
|
|
126
|
+
next unless definition.is_a?(Hash) && definition["steps"].is_a?(Array)
|
|
127
|
+
|
|
128
|
+
definition["steps"] = prenormalize_steps(definition["steps"])
|
|
129
|
+
end
|
|
130
|
+
each_mapping(config, "jobs") do |_name, job|
|
|
131
|
+
next unless job.is_a?(Hash) && job["steps"].is_a?(Array)
|
|
132
|
+
|
|
133
|
+
job["steps"] = prenormalize_steps(job["steps"])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def prenormalize_steps(steps)
|
|
138
|
+
steps.map do |step|
|
|
139
|
+
# Bare `- checkout` parses as the String "checkout", not a mapping.
|
|
140
|
+
if step.is_a?(String)
|
|
141
|
+
{ step => {} }
|
|
142
|
+
elsif step.is_a?(Hash) && step.size == 1
|
|
143
|
+
type, cfg = step.first
|
|
144
|
+
case type.to_s
|
|
145
|
+
when "run" then cfg.is_a?(String) ? { "run" => { "command" => cfg } } : step
|
|
146
|
+
when "checkout" then cfg.nil? ? { "checkout" => {} } : step
|
|
147
|
+
else step
|
|
148
|
+
end
|
|
149
|
+
else
|
|
150
|
+
step
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- reusable commands -----------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# Inlines `commands:` call sites. A step whose single key matches a
|
|
158
|
+
# command name is replaced by the command's steps, with
|
|
159
|
+
# << parameters.x >> substituted from the call-site overrides (or the
|
|
160
|
+
# command parameter defaults). References to anything else are left for
|
|
161
|
+
# the global interpolation pass.
|
|
162
|
+
def expand_commands(config, errors)
|
|
163
|
+
commands = config["commands"]
|
|
164
|
+
return unless commands.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
each_mapping(config, "jobs") do |job_name, job|
|
|
167
|
+
next unless job.is_a?(Hash) && job["steps"].is_a?(Array)
|
|
168
|
+
|
|
169
|
+
path = ["jobs", job_name, "steps"]
|
|
170
|
+
expanded = []
|
|
171
|
+
job["steps"].each_with_index do |step, i|
|
|
172
|
+
expanded.concat(expand_step(step, commands, errors, path + [i], 0))
|
|
173
|
+
end
|
|
174
|
+
job["steps"] = expanded
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def expand_step(step, commands, errors, path, depth)
|
|
179
|
+
if depth > 10
|
|
180
|
+
errors.add(path, "command expansion exceeded 10 levels; check for recursive command calls")
|
|
181
|
+
return [step]
|
|
182
|
+
end
|
|
183
|
+
return [step] unless step.is_a?(Hash) && step.size == 1
|
|
184
|
+
|
|
185
|
+
key, value = step.first
|
|
186
|
+
key = key.to_s
|
|
187
|
+
return [step] unless commands.key?(key)
|
|
188
|
+
|
|
189
|
+
definition = commands[key]
|
|
190
|
+
params_def = (definition.is_a?(Hash) && definition["parameters"]) || {}
|
|
191
|
+
unless value.nil? || value.is_a?(Hash)
|
|
192
|
+
errors.add(path, "arguments to command #{key.inspect} must be a mapping of parameter names to values")
|
|
193
|
+
return [step]
|
|
194
|
+
end
|
|
195
|
+
overrides = (value || {}).transform_keys(&:to_s)
|
|
196
|
+
|
|
197
|
+
values = resolve_call_params(params_def, overrides, errors, path, "command #{key.inspect}")
|
|
198
|
+
steps = deep_dup(definition["steps"] || [])
|
|
199
|
+
local_ctx = { "parameters" => values, "pipeline" => {}, "matrix" => {} }
|
|
200
|
+
only = values.keys.map { |name| "parameters.#{name}" }
|
|
201
|
+
steps = Interpolator.interpolate(steps, local_ctx, errors, path, only: only)
|
|
202
|
+
steps.flat_map { |s| expand_step(s, commands, errors, path, depth + 1) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def resolve_call_params(params_def, overrides, errors, path, owner)
|
|
206
|
+
params_def = params_def.is_a?(Hash) ? params_def : {}
|
|
207
|
+
unknown = overrides.keys - params_def.keys.map(&:to_s)
|
|
208
|
+
unknown.each { |u| errors.add(path, "#{owner} has no parameter #{u.inspect}") }
|
|
209
|
+
|
|
210
|
+
values = {}
|
|
211
|
+
params_def.each do |pname, pdef|
|
|
212
|
+
pname = pname.to_s
|
|
213
|
+
if overrides.key?(pname)
|
|
214
|
+
values[pname] = overrides[pname]
|
|
215
|
+
elsif pdef.is_a?(Hash) && pdef.key?("default")
|
|
216
|
+
values[pname] = pdef["default"]
|
|
217
|
+
else
|
|
218
|
+
errors.add(path, "#{owner} requires parameter #{pname.inspect} but no value was given")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
values
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# --- reusable executors ----------------------------------------------------
|
|
225
|
+
|
|
226
|
+
# Resolves `executor:` references on jobs. Supports both forms:
|
|
227
|
+
# executor: my-executor
|
|
228
|
+
# executor: {name: my-executor, ruby_version: "3.2"}
|
|
229
|
+
# Job-level keys win over the executor definition (environment maps merge).
|
|
230
|
+
def expand_executors(config, errors)
|
|
231
|
+
executors = config["executors"]
|
|
232
|
+
return unless executors.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
each_mapping(config, "jobs") do |job_name, job|
|
|
235
|
+
next unless job.is_a?(Hash)
|
|
236
|
+
|
|
237
|
+
path = ["jobs", job_name]
|
|
238
|
+
ref = job["executor"]
|
|
239
|
+
next if ref.nil?
|
|
240
|
+
|
|
241
|
+
if job.key?("docker")
|
|
242
|
+
errors.add(path, "job #{job_name.inspect} declares both \"docker:\" and \"executor:\"; use one")
|
|
243
|
+
next
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
name, overrides = parse_executor_ref(ref, errors, path + ["executor"])
|
|
247
|
+
next if name.nil?
|
|
248
|
+
|
|
249
|
+
definition = executors[name]
|
|
250
|
+
if definition.nil?
|
|
251
|
+
errors.add(path + ["executor"], "unknown executor #{name.inspect}; " \
|
|
252
|
+
"defined executors: #{executors.keys.map(&:to_s).sort.join(', ')}")
|
|
253
|
+
next
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
values = resolve_call_params(definition["parameters"], overrides, errors, path + ["executor"], "executor #{name.inspect}")
|
|
257
|
+
expanded = deep_dup(definition)
|
|
258
|
+
expanded.delete("parameters")
|
|
259
|
+
only = values.keys.map { |pname| "parameters.#{pname}" }
|
|
260
|
+
expanded = Interpolator.interpolate(expanded, { "parameters" => values, "pipeline" => {}, "matrix" => {} },
|
|
261
|
+
errors, path + ["executor"], only: only)
|
|
262
|
+
|
|
263
|
+
expanded.each do |key, value|
|
|
264
|
+
if key == "environment" && job["environment"].is_a?(Hash) && value.is_a?(Hash)
|
|
265
|
+
job["environment"] = value.merge(job["environment"])
|
|
266
|
+
elsif !job.key?(key)
|
|
267
|
+
job[key] = value
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
job.delete("executor")
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def parse_executor_ref(ref, errors, path)
|
|
275
|
+
case ref
|
|
276
|
+
when String
|
|
277
|
+
[ref, {}]
|
|
278
|
+
when Hash
|
|
279
|
+
if ref.size == 1 && !ref.key?("name")
|
|
280
|
+
name, params = ref.first
|
|
281
|
+
[name.to_s, params.is_a?(Hash) ? params.transform_keys(&:to_s) : {}]
|
|
282
|
+
elsif ref["name"]
|
|
283
|
+
[ref["name"].to_s, ref.reject { |k, _| k.to_s == "name" }.transform_keys(&:to_s)]
|
|
284
|
+
else
|
|
285
|
+
errors.add(path, "\"executor:\" mapping needs a \"name:\" key")
|
|
286
|
+
[nil, {}]
|
|
287
|
+
end
|
|
288
|
+
else
|
|
289
|
+
errors.add(path, "\"executor:\" must be an executor name or a mapping with \"name:\"")
|
|
290
|
+
[nil, {}]
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# --- pipeline parameters ---------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def resolve_parameters(params_def, overrides, errors, warnings)
|
|
297
|
+
params_def = params_def.is_a?(Hash) ? params_def : {}
|
|
298
|
+
unknown = overrides.keys - params_def.keys.map(&:to_s)
|
|
299
|
+
unknown.each do |name|
|
|
300
|
+
warnings << "pipeline_params references unknown parameter #{name.inspect}; ignored"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
values = {}
|
|
304
|
+
params_def.each do |name, definition|
|
|
305
|
+
name = name.to_s
|
|
306
|
+
type = (definition.is_a?(Hash) && definition["type"] || "string").to_s
|
|
307
|
+
if overrides.key?(name)
|
|
308
|
+
value = overrides[name]
|
|
309
|
+
unless Validator.value_matches_type?(value, type)
|
|
310
|
+
errors.add(["parameters", name],
|
|
311
|
+
"override value #{value.inspect} does not match declared type #{type.inspect}")
|
|
312
|
+
next
|
|
313
|
+
end
|
|
314
|
+
values[name] = value
|
|
315
|
+
elsif definition.is_a?(Hash) && definition.key?("default")
|
|
316
|
+
values[name] = definition["default"]
|
|
317
|
+
else
|
|
318
|
+
errors.add(["parameters", name],
|
|
319
|
+
"parameter #{name.inspect} has no default and no value was supplied via pipeline_params")
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
values
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# --- workflows ---------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def build_workflows(config, errors, ctx, warnings, on_missing)
|
|
328
|
+
workflows_cfg = config["workflows"]
|
|
329
|
+
jobs_def = config["jobs"] || {}
|
|
330
|
+
|
|
331
|
+
if !workflows_cfg.is_a?(Hash) || workflows_cfg.empty?
|
|
332
|
+
warnings << "no \"workflows:\" defined; running all jobs in a single implicit \"default\" workflow"
|
|
333
|
+
names = jobs_def.keys.map(&:to_s)
|
|
334
|
+
return [IR.workflow("default", names.map { |n| build_job_ir(n, n, {}, jobs_def, errors, ctx, on_missing) })]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
workflows_cfg.map do |wf_name, wf|
|
|
338
|
+
build_workflow(wf_name.to_s, wf, jobs_def, errors, ctx, warnings, on_missing)
|
|
339
|
+
end.compact
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def build_workflow(wf_name, wf, jobs_def, errors, ctx, warnings, on_missing)
|
|
343
|
+
branch = ctx["pipeline"]["branch"]
|
|
344
|
+
|
|
345
|
+
# 1. Expand matrix entries into concrete job instances.
|
|
346
|
+
instances = [] # [instance_name, base_job_name, entry_cfg, matrix_values]
|
|
347
|
+
sources = Hash.new { |h, k| h[k] = [] } # base name or matrix alias -> [instance names]
|
|
348
|
+
(wf["jobs"] || []).each_with_index do |entry, i|
|
|
349
|
+
job_name, cfg = Validator::CompilerEntry.parse(entry)
|
|
350
|
+
path = ["workflows", wf_name, "jobs", i]
|
|
351
|
+
|
|
352
|
+
if cfg["matrix"]
|
|
353
|
+
combos = matrix_combinations(cfg["matrix"]["parameters"] || {})
|
|
354
|
+
combos.each_with_index do |combo, idx|
|
|
355
|
+
inst = "#{job_name}-#{idx}"
|
|
356
|
+
instances << [inst, job_name, cfg, combo]
|
|
357
|
+
sources[job_name] << inst
|
|
358
|
+
sources[cfg["matrix"]["alias"].to_s] << inst if cfg["matrix"]["alias"]
|
|
359
|
+
end
|
|
360
|
+
else
|
|
361
|
+
instances << [job_name, job_name, cfg, {}]
|
|
362
|
+
sources[job_name] << job_name
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
if (filters = cfg["filters"]) && filters.is_a?(Hash)
|
|
366
|
+
extra = filters.keys.map(&:to_s) - ["branches"]
|
|
367
|
+
unless extra.empty?
|
|
368
|
+
warnings << "workflows.#{wf_name}: only \"branches\" filters are supported in v1; " \
|
|
369
|
+
"#{extra.map(&:inspect).join(', ')} on job #{job_name.inspect} will be ignored"
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
seen = {}
|
|
375
|
+
instances.each do |inst, _base, _cfg, _combo|
|
|
376
|
+
if seen[inst]
|
|
377
|
+
errors.add(["workflows", wf_name], "job #{inst.inspect} appears more than once in the workflow")
|
|
378
|
+
end
|
|
379
|
+
seen[inst] = true
|
|
380
|
+
end
|
|
381
|
+
return nil if errors.any?
|
|
382
|
+
|
|
383
|
+
# 2. Branch filters decide which instances are active.
|
|
384
|
+
active = {}
|
|
385
|
+
instances.each do |inst, _base, cfg, _combo|
|
|
386
|
+
filters = cfg["filters"] || {}
|
|
387
|
+
if filters["branches"] && branch.nil?
|
|
388
|
+
warnings << "no branch supplied via pipeline_params; branch filters for job #{inst.inspect} not applied"
|
|
389
|
+
active[inst] = true
|
|
390
|
+
else
|
|
391
|
+
active[inst] = branch_matches?(filters, branch)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# 3. Resolve requires edges (names and matrix aliases both work).
|
|
396
|
+
edges = {}
|
|
397
|
+
instances.each_with_index do |(inst, _base, cfg, _combo), i|
|
|
398
|
+
path = ["workflows", wf_name, "jobs", i]
|
|
399
|
+
deps = []
|
|
400
|
+
Array(cfg["requires"]).each do |req|
|
|
401
|
+
targets = sources[req.to_s]
|
|
402
|
+
if targets.nil? || targets.empty?
|
|
403
|
+
errors.add(path + [inst, "requires"],
|
|
404
|
+
"job #{inst.inspect} requires unknown job or matrix alias #{req.inspect}")
|
|
405
|
+
next
|
|
406
|
+
end
|
|
407
|
+
targets.each do |target|
|
|
408
|
+
if active[target]
|
|
409
|
+
deps << target
|
|
410
|
+
else
|
|
411
|
+
warnings << "job #{inst.inspect} requires #{target.inspect}, which was filtered out " \
|
|
412
|
+
"by branch filters; treating the dependency as satisfied"
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
edges[inst] = deps.uniq
|
|
417
|
+
end
|
|
418
|
+
return nil if errors.any?
|
|
419
|
+
|
|
420
|
+
# 4. Cycle detection + topological order over active instances only.
|
|
421
|
+
active_names = instances.map(&:first).select { |n| active[n] }
|
|
422
|
+
pruned = active_names.to_h { |n| [n, edges[n].select { |d| active[d] }] }
|
|
423
|
+
if (cycle = DAG.find_cycle(active_names, pruned))
|
|
424
|
+
errors.add(["workflows", wf_name], "dependency cycle detected: #{cycle.join(' -> ')}")
|
|
425
|
+
return nil
|
|
426
|
+
end
|
|
427
|
+
order = DAG.toposort(active_names, pruned)
|
|
428
|
+
|
|
429
|
+
# 5. Emit IR jobs in topological order.
|
|
430
|
+
lookup = instances.to_h { |inst, base, cfg, combo| [inst, [base, cfg, combo]] }
|
|
431
|
+
jobs_ir = order.map do |inst|
|
|
432
|
+
base, _cfg, combo = lookup[inst]
|
|
433
|
+
build_job_ir(inst, base, combo, jobs_def, errors, ctx, on_missing, pruned[inst] || [])
|
|
434
|
+
end
|
|
435
|
+
return nil if errors.any?
|
|
436
|
+
|
|
437
|
+
IR.workflow(wf_name, jobs_ir)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def build_job_ir(instance_name, base_name, matrix_values, jobs_def, errors, ctx, on_missing, requires = [])
|
|
441
|
+
job_def = deep_dup(jobs_def[base_name])
|
|
442
|
+
job_ctx = ctx.merge("matrix" => matrix_values)
|
|
443
|
+
job_def = Interpolator.interpolate(job_def, job_ctx, errors, ["jobs", base_name], on_missing: on_missing)
|
|
444
|
+
# Anything still unresolved here is a real problem — except pipeline
|
|
445
|
+
# values, which were deliberately left with a warning above.
|
|
446
|
+
leftover = Interpolator.unresolved_refs(job_def).reject { |r| Interpolator.pipeline_ref?(r) }
|
|
447
|
+
unless leftover.empty?
|
|
448
|
+
errors.add(["jobs", base_name],
|
|
449
|
+
"unresolved interpolation #{leftover.map { |r| "<< #{r} >>" }.join(', ')}; " \
|
|
450
|
+
"matrix references are only allowed inside matrix jobs")
|
|
451
|
+
return nil
|
|
452
|
+
end
|
|
453
|
+
job_def["steps"] = evaluate_conditionals(job_def["steps"] || [], errors, ["jobs", base_name, "steps"])
|
|
454
|
+
IR.job(instance_name, job_def, requires)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def matrix_combinations(parameters)
|
|
458
|
+
keys = parameters.keys.map(&:to_s)
|
|
459
|
+
values = keys.map { |k| Array(parameters[k]) }
|
|
460
|
+
values.first.product(*values.drop(1)).map do |combo|
|
|
461
|
+
keys.zip(Array(combo)).to_h
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# --- branch filters ------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
def branch_matches?(filters, branch)
|
|
468
|
+
branches = (filters || {})["branches"] || {}
|
|
469
|
+
only = branches["only"]
|
|
470
|
+
ignore = branches["ignore"]
|
|
471
|
+
return false if only && !matches_any?(only, branch)
|
|
472
|
+
return false if ignore && matches_any?(ignore, branch)
|
|
473
|
+
|
|
474
|
+
true
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def matches_any?(patterns, branch)
|
|
478
|
+
Array(patterns).any? do |pattern|
|
|
479
|
+
if pattern.start_with?("/") && pattern.end_with?("/") && pattern.length > 1
|
|
480
|
+
begin
|
|
481
|
+
Regexp.new(pattern[1..-2]).match?(branch.to_s)
|
|
482
|
+
rescue RegexpError
|
|
483
|
+
false
|
|
484
|
+
end
|
|
485
|
+
else
|
|
486
|
+
pattern == branch
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# --- when / unless conditional steps ----------------------------------------------
|
|
492
|
+
|
|
493
|
+
# Evaluates compile-time conditional steps. Conditions were interpolated
|
|
494
|
+
# already, so they are booleans (or "true"/"false" strings).
|
|
495
|
+
def evaluate_conditionals(steps, errors, path)
|
|
496
|
+
result = []
|
|
497
|
+
steps.each_with_index do |step, i|
|
|
498
|
+
unless step.is_a?(Hash) && step.size == 1
|
|
499
|
+
result << step
|
|
500
|
+
next
|
|
501
|
+
end
|
|
502
|
+
type, cfg = step.first
|
|
503
|
+
unless %w[when unless].include?(type.to_s)
|
|
504
|
+
result << step
|
|
505
|
+
next
|
|
506
|
+
end
|
|
507
|
+
cond = truthy?(cfg["condition"], errors, path + [i, type, "condition"])
|
|
508
|
+
include_steps = type.to_s == "when" ? cond : !cond
|
|
509
|
+
next unless include_steps
|
|
510
|
+
|
|
511
|
+
result.concat(evaluate_conditionals(cfg["steps"] || [], errors, path + [i, type, "steps"]))
|
|
512
|
+
end
|
|
513
|
+
result
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def truthy?(value, errors, path)
|
|
517
|
+
case value
|
|
518
|
+
when true, "true" then true
|
|
519
|
+
when false, "false" then false
|
|
520
|
+
else
|
|
521
|
+
errors.add(path, "condition must resolve to true/false (got #{value.inspect}); " \
|
|
522
|
+
"use a boolean parameter like \"<< parameters.run_lint >>\"")
|
|
523
|
+
false
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# --- misc --------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
def deep_dup(obj)
|
|
530
|
+
case obj
|
|
531
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
532
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
533
|
+
else obj
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
data/lib/forge_ci/dag.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForgeCI
|
|
4
|
+
# Directed-acyclic-graph helpers for workflow `requires` edges.
|
|
5
|
+
#
|
|
6
|
+
# nodes - job names in config order (stable tie-breaking)
|
|
7
|
+
# edges - Hash of job name => Array of names it depends on
|
|
8
|
+
module DAG
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Returns an Array describing the cycle (e.g. ["a", "b", "a"]) or nil.
|
|
12
|
+
def find_cycle(nodes, edges)
|
|
13
|
+
visited = {} # name => :visiting | :done
|
|
14
|
+
stack = []
|
|
15
|
+
|
|
16
|
+
visit = lambda do |node|
|
|
17
|
+
visited[node] = :visiting
|
|
18
|
+
stack << node
|
|
19
|
+
(edges[node] || []).each do |dep|
|
|
20
|
+
if visited[dep] == :visiting
|
|
21
|
+
return stack.drop(stack.index(dep)) + [dep]
|
|
22
|
+
elsif !visited.key?(dep)
|
|
23
|
+
cycle = visit.call(dep)
|
|
24
|
+
return cycle if cycle
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
stack.pop
|
|
28
|
+
visited[node] = :done
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
nodes.each do |node|
|
|
33
|
+
next if visited.key?(node)
|
|
34
|
+
|
|
35
|
+
cycle = visit.call(node)
|
|
36
|
+
return cycle if cycle
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Kahn's algorithm, stable: among ready nodes, config order wins.
|
|
42
|
+
# Assumes the graph is acyclic (run find_cycle first).
|
|
43
|
+
def toposort(nodes, edges)
|
|
44
|
+
indegree = Hash.new(0)
|
|
45
|
+
dependents = Hash.new { |h, k| h[k] = [] }
|
|
46
|
+
nodes.each do |node|
|
|
47
|
+
(edges[node] || []).each do |dep|
|
|
48
|
+
indegree[node] += 1
|
|
49
|
+
dependents[dep] << node
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
ready = nodes.select { |n| indegree[n].zero? }
|
|
54
|
+
order = []
|
|
55
|
+
until ready.empty?
|
|
56
|
+
node = ready.shift
|
|
57
|
+
order << node
|
|
58
|
+
dependents[node].each do |dep|
|
|
59
|
+
indegree[dep] -= 1
|
|
60
|
+
ready << dep if indegree[dep].zero?
|
|
61
|
+
end
|
|
62
|
+
# Keep config order among newly-ready nodes.
|
|
63
|
+
ready.sort_by! { |n| nodes.index(n) }
|
|
64
|
+
end
|
|
65
|
+
order
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|