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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ module ForgeCI
6
+ # Raised only when the YAML text cannot be parsed at all.
7
+ # Every other problem (unknown keys, bad references, cycles, ...) is
8
+ # reported as an entry in the errors array returned by Compiler.compile,
9
+ # so callers get actionable feedback instead of exceptions.
10
+ class Error < StandardError; end
11
+
12
+ # Maps a key path (e.g. ["jobs", "build", "steps"]) back to the line number
13
+ # of that key in the original YAML document, so error messages can point
14
+ # at the user's YAML. Built from the Psych AST; tolerant of anchors and
15
+ # merge keys.
16
+ class LineIndex
17
+ def initialize(document)
18
+ @root = document.root
19
+ @anchors = {}
20
+ index_anchors(@root)
21
+ end
22
+
23
+ # Returns the 1-based line number of the key at the end of +path+,
24
+ # or nil when it cannot be located (e.g. after alias expansion).
25
+ # Integer path elements index into YAML sequences (step lists, etc.).
26
+ def line_for(*path)
27
+ node = @root
28
+ key_node = nil
29
+ path.each do |key|
30
+ node = resolve(node)
31
+ if node.is_a?(Psych::Nodes::Sequence) && key.is_a?(Integer)
32
+ key_node = node.children[key]
33
+ node = key_node
34
+ next
35
+ end
36
+ return nil unless node.is_a?(Psych::Nodes::Mapping)
37
+
38
+ i = mapping_key_index(node, key.to_s)
39
+ return nil if i.nil?
40
+
41
+ key_node = node.children[i]
42
+ node = node.children[i + 1]
43
+ end
44
+ key_node ? key_node.start_line + 1 : nil
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ private
50
+
51
+ def index_anchors(node)
52
+ return unless node.respond_to?(:children)
53
+
54
+ @anchors[node.anchor] = node if node.respond_to?(:anchor) && node.anchor
55
+ children = node.children
56
+ return if children.nil?
57
+
58
+ children.each { |child| index_anchors(child) }
59
+ end
60
+
61
+ def resolve(node)
62
+ node.is_a?(Psych::Nodes::Alias) ? (@anchors[node.anchor] || node) : node
63
+ end
64
+
65
+ def mapping_key_index(map, key)
66
+ map.children.each_slice(2).with_index do |(k, _v), i|
67
+ k = resolve(k)
68
+ return i * 2 if k.is_a?(Psych::Nodes::Scalar) && k.value == key
69
+ end
70
+ nil
71
+ end
72
+ end
73
+
74
+ # Collects validation errors as structured entries and renders them as
75
+ # actionable strings: "line 12: jobs.build: <message>".
76
+ class ErrorCollector
77
+ Entry = Struct.new(:path, :line, :message, keyword_init: true)
78
+
79
+ def initialize(line_index = nil)
80
+ @line_index = line_index
81
+ @entries = []
82
+ end
83
+
84
+ def add(path, message)
85
+ path = Array(path)
86
+ line = @line_index&.line_for(*path)
87
+ @entries << Entry.new(path: path.join("."), line: line, message: message)
88
+ end
89
+
90
+ def any? = @entries.any?
91
+ def empty? = @entries.empty?
92
+ def size = @entries.size
93
+
94
+ def to_a
95
+ @entries.map do |e|
96
+ loc = e.line ? "line #{e.line}: " : ""
97
+ "#{loc}#{e.path}: #{e.message}"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForgeCI
4
+ # Pure string-substitution pass for CircleCI-style template references:
5
+ #
6
+ # << parameters.deploy_env >> pipeline parameter (top-level `parameters:`)
7
+ # << pipeline.git.branch >> supplied via pipeline_params["branch"]
8
+ # << pipeline.number >> supplied via pipeline_params["number"]
9
+ # << matrix.node_version >> only while desugaring matrix jobs
10
+ #
11
+ # Anything else is an error (collected, not raised). When the whole string
12
+ # is a single reference, the native Ruby value is preserved (so booleans
13
+ # and integers survive); embedded references are stringified.
14
+ #
15
+ # Two scoping knobs exist because references resolve in stages:
16
+ # - only: allowlist of full reference strings (e.g. a command's own
17
+ # parameter names); anything else is left untouched for a later pass.
18
+ # - skip_prefixes: denylist of reference prefixes left untouched
19
+ # (e.g. "matrix." during the global pass; matrix jobs resolve them later).
20
+ module Interpolator
21
+ REF = /<<\s*([^<>]+?)\s*>>/
22
+ FULL_REF = /\A#{REF.source}\z/
23
+
24
+ module_function
25
+
26
+ # context is a Hash with string keys: "parameters", "pipeline", "matrix".
27
+ def interpolate(obj, context, errors, path, only: nil, skip_prefixes: [], on_missing: nil)
28
+ case obj
29
+ when String
30
+ interpolate_string(obj, context, errors, path, only: only, skip_prefixes: skip_prefixes, on_missing: on_missing)
31
+ when Array
32
+ obj.map.with_index { |item, i| interpolate(item, context, errors, path + [i], only: only, skip_prefixes: skip_prefixes, on_missing: on_missing) }
33
+ when Hash
34
+ obj.each_with_object({}) do |(k, v), out|
35
+ new_key = k.is_a?(String) ? interpolate_string(k, context, errors, path, only: only, skip_prefixes: skip_prefixes, on_missing: on_missing) : k
36
+ out[new_key] = interpolate(v, context, errors, path + [k], only: only, skip_prefixes: skip_prefixes, on_missing: on_missing)
37
+ end
38
+ else
39
+ obj
40
+ end
41
+ end
42
+
43
+ def interpolate_string(str, context, errors, path, only: nil, skip_prefixes: [], on_missing: nil)
44
+ return str unless str.match?(REF)
45
+
46
+ if (m = str.match(FULL_REF))
47
+ ref = m[1].strip
48
+ return str if passthrough?(ref, only, skip_prefixes)
49
+
50
+ found, value = resolve(ref, context)
51
+ return value if found
52
+ return on_missing.call(ref, path) if on_missing && pipeline_ref?(ref)
53
+
54
+ errors.add(path, "unknown interpolation reference \"<< #{ref} >>\"; " \
55
+ "expected parameters.<name>, pipeline.git.branch, pipeline.number, or matrix.<name>")
56
+ return str
57
+ end
58
+
59
+ str.gsub(REF) do
60
+ ref = Regexp.last_match(1).strip
61
+ if passthrough?(ref, only, skip_prefixes)
62
+ "<< #{ref} >>"
63
+ else
64
+ found, value = resolve(ref, context)
65
+ if found
66
+ value.to_s
67
+ elsif on_missing && pipeline_ref?(ref)
68
+ on_missing.call(ref, path)
69
+ else
70
+ errors.add(path, "unknown interpolation reference \"<< #{ref} >>\"; " \
71
+ "expected parameters.<name>, pipeline.git.branch, pipeline.number, or matrix.<name>")
72
+ "<< #{ref} >>"
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ # Returns [found, value]. Unknown references return [false, nil].
79
+ def resolve(ref, context)
80
+ case ref
81
+ when /\Aparameters\.(.+)\z/
82
+ params = context["parameters"] || {}
83
+ name = Regexp.last_match(1)
84
+ return [true, params[name]] if params.key?(name)
85
+
86
+ [false, nil]
87
+ when "pipeline.git.branch"
88
+ pipeline = context["pipeline"] || {}
89
+ return [true, pipeline["branch"]] unless pipeline["branch"].nil?
90
+
91
+ [false, nil]
92
+ when "pipeline.number"
93
+ pipeline = context["pipeline"] || {}
94
+ return [true, pipeline["number"]] unless pipeline["number"].nil?
95
+
96
+ [false, nil]
97
+ when /\Amatrix\.(.+)\z/
98
+ matrix = context["matrix"] || {}
99
+ name = Regexp.last_match(1)
100
+ return [true, matrix[name]] if matrix.key?(name)
101
+
102
+ [false, nil]
103
+ else
104
+ [false, nil]
105
+ end
106
+ end
107
+
108
+ # All "<< ... >>" references still present in the object (after passes
109
+ # that intentionally left some unresolved).
110
+ def unresolved_refs(obj)
111
+ refs = []
112
+ walk = lambda do |o|
113
+ case o
114
+ when String then o.scan(REF) { refs << Regexp.last_match(1).strip }
115
+ when Array then o.each { |item| walk.call(item) }
116
+ when Hash then o.each { |k, v| walk.call(k); walk.call(v) }
117
+ end
118
+ end
119
+ walk.call(obj)
120
+ refs.uniq
121
+ end
122
+
123
+ def passthrough?(ref, only, skip_prefixes)
124
+ return true if skip_prefixes.any? { |prefix| ref.start_with?(prefix) }
125
+ return true if only && !only.include?(ref)
126
+
127
+ false
128
+ end
129
+
130
+ # << pipeline.git.branch >> / << pipeline.number >> are valid references
131
+ # even when no value was supplied; the compiler leaves them unresolved
132
+ # with a warning instead of erroring.
133
+ def pipeline_ref?(ref)
134
+ ref == "pipeline.git.branch" || ref == "pipeline.number"
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForgeCI
4
+ # Normalizes the validated, expanded, interpolated config into the
5
+ # intermediate representation (IR): plain Ruby hashes with symbol keys,
6
+ # JSON-serializable, ready for a scheduler/runner to consume.
7
+ #
8
+ # Only called when validation produced no errors, so normalization can
9
+ # assume well-formed input and focus on shaping.
10
+ module IR
11
+ module_function
12
+
13
+ def document(version, workflows, warnings)
14
+ {
15
+ version: version.to_s,
16
+ workflows: workflows,
17
+ meta: { warnings: warnings }
18
+ }
19
+ end
20
+
21
+ def workflow(name, jobs)
22
+ { name: name, jobs: jobs }
23
+ end
24
+
25
+ def job(name, job_def, requires = [])
26
+ {
27
+ name: name,
28
+ # Pruned `requires` edges for this job instance (topological scheduler
29
+ # seam for the control plane): names of jobs in the same workflow that
30
+ # must complete successfully before this job may run.
31
+ requires: Array(requires).map(&:to_s),
32
+ executor: executor(job_def),
33
+ working_directory: job_def["working_directory"],
34
+ steps: Array(job_def["steps"]).map { |s| step(s) }
35
+ }
36
+ end
37
+
38
+ def executor(job_def)
39
+ images = Array(job_def["docker"])
40
+ primary = images.first || {}
41
+ {
42
+ type: "docker",
43
+ image: primary["image"],
44
+ name: primary["name"],
45
+ entrypoint: primary["entrypoint"],
46
+ command: primary["command"],
47
+ environment: merged_environment(primary["environment"], job_def["environment"]),
48
+ secondary_images: images.drop(1).map do |img|
49
+ {
50
+ image: img["image"],
51
+ name: img["name"],
52
+ entrypoint: img["entrypoint"],
53
+ command: img["command"],
54
+ environment: img["environment"] || {}
55
+ }
56
+ end,
57
+ resource_class: job_def["resource_class"]
58
+ }
59
+ end
60
+
61
+ def merged_environment(image_env, job_env)
62
+ merged = {}
63
+ merged.merge!(image_env) if image_env.is_a?(Hash)
64
+ merged.merge!(job_env) if job_env.is_a?(Hash)
65
+ merged
66
+ end
67
+
68
+ def step(step)
69
+ type, cfg = step.first
70
+ cfg = {} if cfg.nil?
71
+ type = type.to_s
72
+
73
+ base =
74
+ case type
75
+ when "when", "unless"
76
+ # Conditional wrappers are evaluated at compile time; by the time
77
+ # the IR is built only the surviving steps remain. Reaching here
78
+ # means a bug in the compiler.
79
+ raise Error, "internal error: unevaluated #{type}: step reached IR normalization"
80
+ when "checkout"
81
+ { type: "checkout", path: cfg["path"] }
82
+ when "run"
83
+ {
84
+ type: "run",
85
+ name: cfg["name"] || cfg["command"],
86
+ command: cfg["command"],
87
+ shell: cfg["shell"],
88
+ background: cfg.fetch("background", false),
89
+ when: cfg.fetch("when", "on_success")
90
+ }
91
+ when "save_cache"
92
+ { type: "save_cache", keys: cache_keys(cfg), paths: cfg["paths"] || [] }
93
+ when "restore_cache"
94
+ { type: "restore_cache", keys: cache_keys(cfg) }
95
+ when "persist_to_workspace"
96
+ { type: "persist_to_workspace", root: cfg["root"], paths: cfg["paths"] || [] }
97
+ when "attach_workspace"
98
+ { type: "attach_workspace", at: cfg["at"] }
99
+ when "store_artifacts"
100
+ { type: "store_artifacts", path: cfg["path"], destination: cfg["destination"] }
101
+ when "store_test_results"
102
+ { type: "store_test_results", path: cfg["path"] }
103
+ when "add_ssh_keys"
104
+ { type: "add_ssh_keys", fingerprints: cfg["fingerprints"] || [] }
105
+ else
106
+ raise Error, "internal error: unknown step type #{type.inspect} reached IR normalization"
107
+ end
108
+ base
109
+ end
110
+
111
+ def cache_keys(cfg)
112
+ return cfg["keys"] if cfg["keys"].is_a?(Array)
113
+
114
+ [cfg["key"]].compact
115
+ end
116
+ end
117
+ end