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.
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForgeCI
4
+ # Structural validation of the CircleCI v2.1 subset. Every problem is
5
+ # collected into +errors+ (an ErrorCollector) — nothing is raised — so the
6
+ # caller sees all problems in one pass. Validation runs on the
7
+ # expanded + interpolated config, i.e. after reusable commands/executors
8
+ # have been inlined and << >> references resolved.
9
+ module Validator
10
+ TOP_LEVEL_KEYS = %w[version orbs parameters commands executors jobs workflows].freeze
11
+ SUPPORTED_VERSIONS = %w[2 2.1].freeze
12
+
13
+ # Step types we know how to normalize into the IR.
14
+ STEP_TYPES = %w[
15
+ checkout run save_cache restore_cache
16
+ persist_to_workspace attach_workspace
17
+ store_artifacts store_test_results add_ssh_keys
18
+ ].freeze
19
+
20
+ # Conditional wrappers evaluated at compile time.
21
+ CONDITIONAL_TYPES = %w[when unless].freeze
22
+
23
+ RUN_WHEN_VALUES = %w[on_success on_fail always].freeze
24
+ PARAMETER_TYPES = %w[string boolean integer enum].freeze
25
+ WORKFLOW_JOB_KEYS = %w[requires filters matrix context name].freeze
26
+
27
+ module_function
28
+
29
+ # --- top level ---------------------------------------------------------
30
+
31
+ def top_level(config, errors)
32
+ unknown = config.keys.map(&:to_s) - TOP_LEVEL_KEYS
33
+ unknown.each do |key|
34
+ errors.add([], "unknown top-level key #{key.inspect}; " \
35
+ "supported keys: #{TOP_LEVEL_KEYS.join(', ')}")
36
+ end
37
+
38
+ version = config["version"]
39
+ unless SUPPORTED_VERSIONS.include?(version.to_s)
40
+ errors.add(["version"], "unsupported version #{version.inspect}; expected \"2.1\"")
41
+ end
42
+
43
+ if config.key?("orbs") && !config["orbs"].nil? && config["orbs"] != {}
44
+ errors.add(["orbs"], "orb registry is not supported in forge-ci v1; " \
45
+ "inline the orb's steps/commands into your config instead")
46
+ end
47
+
48
+ unless config["jobs"].is_a?(Hash) && !config["jobs"].empty?
49
+ errors.add(["jobs"], "no jobs defined; add at least one job under \"jobs:\"")
50
+ end
51
+
52
+ %w[parameters commands executors].each do |key|
53
+ if config.key?(key) && !config[key].nil? && !config[key].is_a?(Hash)
54
+ errors.add([key], "\"#{key}:\" must be a mapping")
55
+ end
56
+ end
57
+
58
+ return unless config.key?("workflows") && !config["workflows"].is_a?(Hash)
59
+
60
+ errors.add(["workflows"], "\"workflows:\" must be a mapping of workflow name to definition")
61
+ end
62
+
63
+ # --- pipeline parameters ------------------------------------------------
64
+
65
+ def parameters(params_def, errors)
66
+ return if params_def.nil?
67
+
68
+ unless params_def.is_a?(Hash)
69
+ errors.add(["parameters"], "\"parameters:\" must be a mapping of name to definition")
70
+ return
71
+ end
72
+
73
+ params_def.each do |name, definition|
74
+ path = ["parameters", name]
75
+ unless definition.is_a?(Hash)
76
+ errors.add(path, "parameter definition must be a mapping with \"type\" (and usually \"default\")")
77
+ next
78
+ end
79
+
80
+ type = definition["type"] || "string"
81
+ unless PARAMETER_TYPES.include?(type.to_s)
82
+ errors.add(path + ["type"], "unknown parameter type #{type.inspect}; " \
83
+ "expected one of: #{PARAMETER_TYPES.join(', ')}")
84
+ next
85
+ end
86
+
87
+ if type.to_s == "enum" && !definition["enum"].is_a?(Array)
88
+ errors.add(path, "enum parameters require an \"enum:\" list of allowed values")
89
+ end
90
+
91
+ next unless definition.key?("default")
92
+
93
+ default = definition["default"]
94
+ unless value_matches_type?(default, type.to_s)
95
+ errors.add(path + ["default"],
96
+ "default #{default.inspect} does not match declared type #{type.inspect}")
97
+ end
98
+
99
+ if type.to_s == "enum" && definition["enum"].is_a?(Array) && !definition["enum"].include?(default)
100
+ errors.add(path + ["default"],
101
+ "default #{default.inspect} is not in the enum list #{definition['enum'].inspect}")
102
+ end
103
+ end
104
+ end
105
+
106
+ def value_matches_type?(value, type)
107
+ case type
108
+ when "string" then value.is_a?(String)
109
+ when "boolean" then value == true || value == false
110
+ when "integer" then value.is_a?(Integer)
111
+ when "enum" then true # membership checked separately
112
+ else false
113
+ end
114
+ end
115
+
116
+ # --- reusable commands / executors --------------------------------------
117
+
118
+ def command(name, definition, errors)
119
+ path = ["commands", name]
120
+ unless definition.is_a?(Hash)
121
+ errors.add(path, "command definition must be a mapping with \"steps\" (and optional \"parameters\")")
122
+ return
123
+ end
124
+ parameters(definition["parameters"], errors) if definition.key?("parameters")
125
+ unless definition["steps"].is_a?(Array)
126
+ errors.add(path + ["steps"], "command #{name.inspect} must define a \"steps:\" list")
127
+ end
128
+ end
129
+
130
+ def executor_definition(name, definition, errors)
131
+ path = ["executors", name]
132
+ unless definition.is_a?(Hash)
133
+ errors.add(path, "executor definition must be a mapping")
134
+ return
135
+ end
136
+ parameters(definition["parameters"], errors) if definition.key?("parameters")
137
+ docker = definition["docker"]
138
+ unless docker.is_a?(Array) && !docker.empty?
139
+ errors.add(path + ["docker"],
140
+ "executor #{name.inspect} must define a non-empty \"docker:\" image list (only Docker executors are supported in v1)")
141
+ end
142
+ end
143
+
144
+ # --- jobs ---------------------------------------------------------------
145
+
146
+ def job(name, job, errors)
147
+ path = ["jobs", name]
148
+ unless job.is_a?(Hash)
149
+ errors.add(path, "job definition must be a mapping")
150
+ return
151
+ end
152
+
153
+ docker = job["docker"]
154
+ if docker.nil? || (docker.is_a?(Array) && docker.empty?)
155
+ errors.add(path, "job #{name.inspect} must declare a \"docker:\" executor " \
156
+ "(or reference one via \"executor:\")")
157
+ elsif !docker.is_a?(Array)
158
+ errors.add(path + ["docker"], "\"docker:\" must be a list of image definitions")
159
+ else
160
+ docker.each_with_index { |img, i| docker_image(img, path + ["docker", i], errors) }
161
+ end
162
+
163
+ if job.key?("machine") || job.key?("macos")
164
+ errors.add(path, "only Docker executors are supported in v1; " \
165
+ "\"machine\"/\"macos\" executors are not available yet")
166
+ end
167
+
168
+ steps = job["steps"]
169
+ if !steps.is_a?(Array) || steps.empty?
170
+ errors.add(path + ["steps"], "job #{name.inspect} must define a non-empty \"steps:\" list")
171
+ return
172
+ end
173
+ steps.each_with_index { |step, i| step(step, path + ["steps", i], errors) }
174
+
175
+ return unless job.key?("environment") && !job["environment"].is_a?(Hash)
176
+
177
+ errors.add(path + ["environment"], "\"environment:\" must be a mapping of variable names to values")
178
+ end
179
+
180
+ def docker_image(img, path, errors)
181
+ unless img.is_a?(Hash) && img["image"].is_a?(String) && !img["image"].empty?
182
+ errors.add(path, "each \"docker:\" entry needs an \"image:\" string (e.g. image: cimg/ruby:3.2)")
183
+ return
184
+ end
185
+ return unless img.key?("environment") && !img["environment"].is_a?(Hash)
186
+
187
+ errors.add(path + ["environment"], "\"environment:\" must be a mapping")
188
+ end
189
+
190
+ # --- steps ----------------------------------------------------------------
191
+
192
+ # Validates one step; expects pre-normalized steps (bare `run:` strings
193
+ # already expanded to {command: ...} by the compiler).
194
+ def step(step, path, errors)
195
+ unless step.is_a?(Hash) && step.size == 1
196
+ errors.add(path, "each step must be a mapping with exactly one key " \
197
+ "(e.g. \"- run:\", \"- checkout:\", or a reusable command name)")
198
+ return
199
+ end
200
+
201
+ type, cfg = step.first
202
+ type = type.to_s
203
+
204
+ if CONDITIONAL_TYPES.include?(type)
205
+ conditional_step(cfg, path, errors, type)
206
+ return
207
+ end
208
+
209
+ unless STEP_TYPES.include?(type)
210
+ errors.add(path, "unknown step type #{type.inspect}; " \
211
+ "supported steps: #{(STEP_TYPES + CONDITIONAL_TYPES).join(', ')}")
212
+ return
213
+ end
214
+
215
+ cfg = {} if cfg.nil?
216
+ unless cfg.is_a?(Hash)
217
+ errors.add(path + [type], "step #{type.inspect} expects a mapping of attributes")
218
+ return
219
+ end
220
+
221
+ send("validate_#{type}", cfg, path + [type], errors)
222
+ end
223
+
224
+ def conditional_step(cfg, path, errors, type)
225
+ unless cfg.is_a?(Hash) && cfg.key?("condition") && cfg["steps"].is_a?(Array)
226
+ errors.add(path + [type], "\"#{type}:\" needs \"condition:\" and a \"steps:\" list")
227
+ return
228
+ end
229
+ cfg["steps"].each_with_index { |s, i| step(s, path + [type, "steps", i], errors) }
230
+ end
231
+
232
+ def validate_checkout(cfg, path, errors)
233
+ return unless cfg.key?("path") && !cfg["path"].is_a?(String)
234
+
235
+ errors.add(path + ["path"], "\"path:\" must be a string")
236
+ end
237
+
238
+ def validate_run(cfg, path, errors)
239
+ if cfg["command"].nil? || (cfg["command"].is_a?(String) && cfg["command"].strip.empty?)
240
+ errors.add(path, "\"run:\" needs a \"command:\" (or use the \"run: <command>\" shorthand)")
241
+ end
242
+ if cfg.key?("when") && !RUN_WHEN_VALUES.include?(cfg["when"].to_s)
243
+ errors.add(path + ["when"], "\"when:\" must be one of: #{RUN_WHEN_VALUES.join(', ')}")
244
+ end
245
+ return unless cfg.key?("background") && ![true, false].include?(cfg["background"])
246
+
247
+ errors.add(path + ["background"], "\"background:\" must be true or false")
248
+ end
249
+
250
+ def validate_save_cache(cfg, path, errors)
251
+ validate_cache_keys(cfg, path, errors, "save_cache")
252
+ return unless cfg["paths"].nil? || !cfg["paths"].is_a?(Array) || cfg["paths"].empty?
253
+
254
+ errors.add(path, "\"save_cache:\" needs a non-empty \"paths:\" list of directories/files to cache")
255
+ end
256
+
257
+ def validate_restore_cache(cfg, path, errors)
258
+ validate_cache_keys(cfg, path, errors, "restore_cache")
259
+ end
260
+
261
+ def validate_cache_keys(cfg, path, errors, type)
262
+ keys = cfg["keys"] || (cfg.key?("key") ? [cfg["key"]] : nil)
263
+ if !keys.is_a?(Array) || keys.empty? || keys.any? { |k| !k.is_a?(String) || k.empty? }
264
+ errors.add(path, "\"#{type}:\" needs \"keys:\" as a non-empty list of key template strings")
265
+ end
266
+ end
267
+
268
+ def validate_persist_to_workspace(cfg, path, errors)
269
+ if cfg["root"].nil? || !cfg["root"].is_a?(String) || cfg["root"].empty?
270
+ errors.add(path, "\"persist_to_workspace:\" needs a \"root:\" directory path")
271
+ end
272
+ return unless cfg["paths"].nil? || !cfg["paths"].is_a?(Array) || cfg["paths"].empty?
273
+
274
+ errors.add(path, "\"persist_to_workspace:\" needs a non-empty \"paths:\" list")
275
+ end
276
+
277
+ def validate_attach_workspace(cfg, path, errors)
278
+ return if cfg["at"].is_a?(String) && !cfg["at"].empty?
279
+
280
+ errors.add(path, "\"attach_workspace:\" needs an \"at:\" directory path")
281
+ end
282
+
283
+ def validate_store_artifacts(cfg, path, errors)
284
+ return if cfg["path"].is_a?(String) && !cfg["path"].empty?
285
+
286
+ errors.add(path, "\"store_artifacts:\" needs a \"path:\" to the file or directory to upload")
287
+ end
288
+
289
+ def validate_store_test_results(cfg, path, errors)
290
+ return if cfg["path"].is_a?(String) && !cfg["path"].empty?
291
+
292
+ errors.add(path, "\"store_test_results:\" needs a \"path:\" to the JUnit XML results directory")
293
+ end
294
+
295
+ def validate_add_ssh_keys(cfg, path, errors)
296
+ return unless cfg.key?("fingerprints") && !cfg["fingerprints"].is_a?(Array)
297
+
298
+ errors.add(path + ["fingerprints"], "\"fingerprints:\" must be a list of SSH key fingerprints")
299
+ end
300
+
301
+ # --- workflows ------------------------------------------------------------
302
+
303
+ def workflow(name, wf, errors, known_jobs)
304
+ path = ["workflows", name]
305
+ unless wf.is_a?(Hash) && wf["jobs"].is_a?(Array)
306
+ errors.add(path, "workflow #{name.inspect} must define a \"jobs:\" list")
307
+ return
308
+ end
309
+
310
+ wf["jobs"].each_with_index do |entry, i|
311
+ job_name, cfg = CompilerEntry.parse(entry)
312
+ entry_path = path + ["jobs", i]
313
+
314
+ if job_name.nil?
315
+ errors.add(entry_path, "workflow job entry must be a job name or a mapping of job name to options " \
316
+ "(requires/filters/matrix/context)")
317
+ next
318
+ end
319
+
320
+ unless known_jobs.include?(job_name)
321
+ errors.add(entry_path, "workflow references unknown job #{job_name.inspect}; " \
322
+ "defined jobs: #{known_jobs.sort.join(', ')}")
323
+ next
324
+ end
325
+
326
+ unknown = cfg.keys.map(&:to_s) - WORKFLOW_JOB_KEYS
327
+ unknown.each do |key|
328
+ errors.add(entry_path + [job_name], "unknown workflow job option #{key.inspect}; " \
329
+ "supported: #{WORKFLOW_JOB_KEYS.join(', ')}")
330
+ end
331
+
332
+ if cfg.key?("requires")
333
+ unless cfg["requires"].is_a?(Array) && cfg["requires"].all? { |r| r.is_a?(String) }
334
+ errors.add(entry_path + [job_name, "requires"], "\"requires:\" must be a list of job names")
335
+ end
336
+ end
337
+
338
+ validate_filters(cfg["filters"], entry_path + [job_name, "filters"], errors) if cfg.key?("filters")
339
+ validate_matrix(cfg["matrix"], entry_path + [job_name, "matrix"], errors) if cfg.key?("matrix")
340
+
341
+ next unless cfg.key?("type")
342
+
343
+ errors.add(entry_path + [job_name], "\"type: approval\" jobs are not supported in v1; " \
344
+ "every workflow job runs its steps")
345
+ end
346
+ end
347
+
348
+ def validate_filters(filters, path, errors)
349
+ unless filters.is_a?(Hash)
350
+ errors.add(path, "\"filters:\" must be a mapping")
351
+ return
352
+ end
353
+ branches = filters["branches"]
354
+ return if branches.nil?
355
+
356
+ unless branches.is_a?(Hash)
357
+ errors.add(path + ["branches"], "\"branches:\" must be a mapping with \"only\" and/or \"ignore\" lists")
358
+ return
359
+ end
360
+ %w[only ignore].each do |key|
361
+ next if branches[key].nil?
362
+ next if branches[key].is_a?(Array) && branches[key].all? { |b| b.is_a?(String) }
363
+
364
+ errors.add(path + ["branches", key], "\"#{key}:\" must be a list of branch names " \
365
+ "(plain names or /regex/ patterns)")
366
+ end
367
+ return unless filters.keys.map(&:to_s) != ["branches"]
368
+
369
+ # tags filters etc. are parsed but not honored in v1 — handled by the compiler as a warning.
370
+ end
371
+
372
+ def validate_matrix(matrix, path, errors)
373
+ unless matrix.is_a?(Hash)
374
+ errors.add(path, "\"matrix:\" must be a mapping with \"parameters:\"")
375
+ return
376
+ end
377
+ params = matrix["parameters"]
378
+ if !params.is_a?(Hash) || params.empty? || !params.values.all? { |v| v.is_a?(Array) && !v.empty? }
379
+ errors.add(path + ["parameters"], "\"matrix.parameters:\" must map names to non-empty value lists")
380
+ end
381
+ return if !matrix.key?("alias") || matrix["alias"].is_a?(String)
382
+
383
+ errors.add(path + ["alias"], "\"matrix.alias:\" must be a string")
384
+ end
385
+
386
+ # Parses a workflow "jobs:" entry: either a bare job name String, or a
387
+ # single-key mapping of job name => options hash. Returns [name, options].
388
+ module CompilerEntry
389
+ module_function
390
+
391
+ def parse(entry)
392
+ return [entry, {}] if entry.is_a?(String)
393
+ return [nil, {}] unless entry.is_a?(Hash) && entry.size == 1
394
+
395
+ name, cfg = entry.first
396
+ cfg = {} if cfg.nil?
397
+ return [nil, {}] unless cfg.is_a?(Hash)
398
+
399
+ [name.to_s, cfg]
400
+ end
401
+ end
402
+ end
403
+ end
data/lib/forge_ci.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ require_relative "forge_ci/errors"
6
+ require_relative "forge_ci/interpolator"
7
+ require_relative "forge_ci/dag"
8
+ require_relative "forge_ci/validator"
9
+ require_relative "forge_ci/ir"
10
+ require_relative "forge_ci/compiler"
11
+
12
+ # ForgeCI — an open-source, CircleCI-compatible CI toolkit.
13
+ #
14
+ # This gem (component 1 of the ForgeCI project) is the config compiler:
15
+ # it turns a `.circleci/config.yml`-style YAML document into a normalized,
16
+ # JSON-serializable intermediate representation (IR) that a scheduler and
17
+ # runner can execute. Pure Ruby, stdlib only, no network, no processes.
18
+ module ForgeCI
19
+ VERSION = "0.1.0"
20
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: forge-ci
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Azmi Muwahid
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Compiles CircleCI v2.1-style pipeline YAML into a normalized, JSON-serializable
13
+ intermediate representation. Pure Ruby, stdlib only. Component 1 of the ForgeCI
14
+ open-source CI project.
15
+ email:
16
+ - azmimuwahid@gmail.com
17
+ executables:
18
+ - forge-ci
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - MIT-LICENSE
23
+ - README.md
24
+ - exe/forge-ci
25
+ - lib/forge_ci.rb
26
+ - lib/forge_ci/cli.rb
27
+ - lib/forge_ci/compiler.rb
28
+ - lib/forge_ci/dag.rb
29
+ - lib/forge_ci/errors.rb
30
+ - lib/forge_ci/interpolator.rb
31
+ - lib/forge_ci/ir.rb
32
+ - lib/forge_ci/validator.rb
33
+ homepage: https://github.com/azmi2409/forge-ci
34
+ licenses:
35
+ - MIT
36
+ metadata: {}
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '3.0'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 4.0.4
52
+ specification_version: 4
53
+ summary: 'Open-source CircleCI-compatible CI: config compiler (v1)'
54
+ test_files: []