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,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
|
data/lib/forge_ci/ir.rb
ADDED
|
@@ -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
|