///|
priv struct YamlLine {
indent : Int
text : String
raw : String
}
///|
priv struct YamlField {
key : String
value : YamlValue
}
///|
priv enum YamlValue {
Str(String)
Seq(Array[YamlValue])
Obj(Array[YamlField])
Null
}
///|
priv struct WorkflowYamlParser {
lines : Array[YamlLine]
mut idx : Int
errors : Array[String]
}
///|
fn count_indent(raw : String, line_no : Int, errors : Array[String]) -> Int {
let mut indent = 0
while indent < raw.length() {
let ch = raw.unsafe_get(indent)
if ch == ' ' {
indent += 1
continue
}
if ch == '\t' {
errors.push("line \{line_no}: tabs are not supported in YAML indentation")
}
break
}
indent
}
///|
fn strip_yaml_comment(text : String) -> String {
let mut in_single = false
let mut in_double = false
let mut idx = 0
while idx < text.length() {
let ch = text.unsafe_get(idx)
if ch == '\'' && !in_double {
in_single = !in_single
idx += 1
continue
}
if ch == '"' && !in_single {
in_double = !in_double
idx += 1
continue
}
if ch == '#' && !in_single && !in_double {
return String::unsafe_substring(text, start=0, end=idx)
.trim_end(chars=" \t")
.to_owned()
}
idx += 1
}
text.trim_end(chars=" \t").to_owned()
}
///|
fn collect_yaml_lines(text : String, errors : Array[String]) -> Array[YamlLine] {
let lines : Array[YamlLine] = []
let mut line_no = 1
for line_view in text.split("\n") {
let raw = line_view.to_owned().trim_end(chars="\r").to_owned()
let indent = count_indent(raw, line_no, errors)
let content = if indent >= raw.length() {
""
} else {
String::unsafe_substring(raw, start=indent, end=raw.length())
}
lines.push({ indent, text: strip_yaml_comment(content), raw })
line_no += 1
}
lines
}
///|
fn yaml_parser_new(text : String) -> WorkflowYamlParser {
let errors : Array[String] = []
{ lines: collect_yaml_lines(text, errors), idx: 0, errors }
}
///|
fn WorkflowYamlParser::skip_ignorable(self : WorkflowYamlParser) -> Unit {
while self.idx < self.lines.length() {
let trimmed = self.lines[self.idx].text.trim(chars=" ").to_owned()
if trimmed.length() == 0 || trimmed == "---" || trimmed == "..." {
self.idx += 1
} else {
return
}
}
}
///|
fn is_sequence_item(text : String) -> Bool {
text == "-" || text.has_prefix("- ")
}
///|
fn after_sequence_dash(text : String) -> String {
if text == "-" {
""
} else {
String::unsafe_substring(text, start=2, end=text.length())
.trim_start(chars=" ")
.to_owned()
}
}
///|
fn split_key_value(text : String) -> (String, String?)? {
let mut in_single = false
let mut in_double = false
let mut bracket_depth = 0
let mut brace_depth = 0
let mut idx = 0
while idx < text.length() {
let ch = text.unsafe_get(idx)
if ch == '\'' && !in_double {
in_single = !in_single
idx += 1
continue
}
if ch == '"' && !in_single {
in_double = !in_double
idx += 1
continue
}
if !in_single && !in_double {
if ch == '[' {
bracket_depth += 1
} else if ch == ']' && bracket_depth > 0 {
bracket_depth -= 1
} else if ch == '{' {
brace_depth += 1
} else if ch == '}' && brace_depth > 0 {
brace_depth -= 1
} else if ch == ':' && bracket_depth == 0 && brace_depth == 0 {
let key = String::unsafe_substring(text, start=0, end=idx)
.trim(chars=" ")
.to_owned()
let rest = String::unsafe_substring(
text,
start=idx + 1,
end=text.length(),
)
.trim_start(chars=" ")
.to_owned()
if rest.length() == 0 {
return Some((key, None))
}
return Some((key, Some(rest)))
}
}
idx += 1
}
None
}
///|
fn split_inline_items(text : String) -> Array[String] {
let items : Array[String] = []
let mut in_single = false
let mut in_double = false
let mut bracket_depth = 0
let mut brace_depth = 0
let mut start = 0
let mut idx = 0
while idx < text.length() {
let ch = text.unsafe_get(idx)
if ch == '\'' && !in_double {
in_single = !in_single
idx += 1
continue
}
if ch == '"' && !in_single {
in_double = !in_double
idx += 1
continue
}
if !in_single && !in_double {
if ch == '[' {
bracket_depth += 1
} else if ch == ']' && bracket_depth > 0 {
bracket_depth -= 1
} else if ch == '{' {
brace_depth += 1
} else if ch == '}' && brace_depth > 0 {
brace_depth -= 1
} else if ch == ',' && bracket_depth == 0 && brace_depth == 0 {
items.push(
String::unsafe_substring(text, start~, end=idx)
.trim(chars=" ")
.to_owned(),
)
start = idx + 1
idx += 1
continue
}
}
idx += 1
}
let last = String::unsafe_substring(text, start~, end=text.length())
.trim(chars=" ")
.to_owned()
if last.length() > 0 {
items.push(last)
}
items
}
///|
fn unquote_yaml_string(text : String) -> String {
if text.length() >= 2 {
let first = text.unsafe_get(0)
let last = text.unsafe_get(text.length() - 1)
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
return String::unsafe_substring(text, start=1, end=text.length() - 1)
}
}
text
}
///|
fn parse_inline_map(text : String) -> YamlValue {
let inner = String::unsafe_substring(text, start=1, end=text.length() - 1)
let fields : Array[YamlField] = []
for item in split_inline_items(inner) {
match split_key_value(item) {
Some((key, Some(value))) =>
fields.push({
key: unquote_yaml_string(key),
value: YamlValue::Str(unquote_yaml_string(value)),
})
Some((key, None)) =>
fields.push({ key: unquote_yaml_string(key), value: YamlValue::Null })
None => ()
}
}
YamlValue::Obj(fields)
}
///|
fn parse_inline_value(text : String) -> YamlValue {
let trimmed = text.trim(chars=" ").to_owned()
if trimmed.length() == 0 || trimmed == "null" || trimmed == "~" {
return YamlValue::Null
}
if trimmed.has_prefix("[") && trimmed.has_suffix("]") {
let inner = String::unsafe_substring(
trimmed,
start=1,
end=trimmed.length() - 1,
)
let values : Array[YamlValue] = []
for item in split_inline_items(inner) {
values.push(parse_inline_value(item))
}
return YamlValue::Seq(values)
}
if trimmed.has_prefix("{") && trimmed.has_suffix("}") {
return parse_inline_map(trimmed)
}
YamlValue::Str(unquote_yaml_string(trimmed))
}
///|
fn WorkflowYamlParser::parse_block_scalar(
self : WorkflowYamlParser,
parent_indent : Int,
folded : Bool,
) -> YamlValue {
let scalar_lines : Array[String] = []
let mut scalar_indent = -1
while self.idx < self.lines.length() {
let line = self.lines[self.idx]
if line.text.length() == 0 {
scalar_lines.push("")
self.idx += 1
continue
}
if line.indent <= parent_indent {
break
}
if scalar_indent < 0 {
scalar_indent = line.indent
}
if line.indent < scalar_indent {
break
}
let content = if scalar_indent >= line.raw.length() {
""
} else {
String::unsafe_substring(
line.raw,
start=scalar_indent,
end=line.raw.length(),
)
}
scalar_lines.push(content)
self.idx += 1
}
YamlValue::Str(
if folded {
scalar_lines.join(" ")
} else {
scalar_lines.join("\n")
},
)
}
///|
fn WorkflowYamlParser::parse_mapping_entry(
self : WorkflowYamlParser,
text : String,
expected_indent : Int,
) -> (String, YamlValue)? {
match split_key_value(text) {
Some((raw_key, value_text)) => {
let key = unquote_yaml_string(raw_key)
match value_text {
Some(value) =>
if value == "|" {
Some((key, self.parse_block_scalar(expected_indent, false)))
} else if value == ">" {
Some((key, self.parse_block_scalar(expected_indent, true)))
} else {
Some((key, parse_inline_value(value)))
}
None => {
let saved = self.idx
self.skip_ignorable()
if self.idx < self.lines.length() &&
self.lines[self.idx].indent > expected_indent {
let child_indent = self.lines[self.idx].indent
Some((key, self.parse_block(child_indent)))
} else {
self.idx = saved
Some((key, YamlValue::Null))
}
}
}
}
None => {
self.errors.push("line must be key: value, got '\{text}'")
None
}
}
}
///|
fn WorkflowYamlParser::parse_mapping(
self : WorkflowYamlParser,
indent : Int,
) -> YamlValue {
let fields : Array[YamlField] = []
while true {
self.skip_ignorable()
guard self.idx < self.lines.length() else { break }
let line = self.lines[self.idx]
if line.indent != indent || is_sequence_item(line.text) {
break
}
self.idx += 1
match self.parse_mapping_entry(line.text, indent) {
Some((key, value)) => fields.push({ key, value })
None => ()
}
}
YamlValue::Obj(fields)
}
///|
fn WorkflowYamlParser::parse_sequence(
self : WorkflowYamlParser,
indent : Int,
) -> YamlValue {
let values : Array[YamlValue] = []
while true {
self.skip_ignorable()
guard self.idx < self.lines.length() else { break }
let line = self.lines[self.idx]
if line.indent != indent || !is_sequence_item(line.text) {
break
}
let item_text = after_sequence_dash(line.text)
self.idx += 1
if item_text.length() == 0 {
let saved = self.idx
self.skip_ignorable()
if self.idx < self.lines.length() && self.lines[self.idx].indent > indent {
values.push(self.parse_block(self.lines[self.idx].indent))
} else {
self.idx = saved
values.push(YamlValue::Null)
}
continue
}
if split_key_value(item_text) is Some(_) {
let fields : Array[YamlField] = []
match self.parse_mapping_entry(item_text, indent + 2) {
Some((key, value)) => fields.push({ key, value })
None => ()
}
while true {
self.skip_ignorable()
guard self.idx < self.lines.length() else { break }
let next = self.lines[self.idx]
if next.indent != indent + 2 || is_sequence_item(next.text) {
break
}
self.idx += 1
match self.parse_mapping_entry(next.text, indent + 2) {
Some((key, value)) => fields.push({ key, value })
None => ()
}
}
values.push(YamlValue::Obj(fields))
continue
}
values.push(parse_inline_value(item_text))
}
YamlValue::Seq(values)
}
///|
fn WorkflowYamlParser::parse_block(
self : WorkflowYamlParser,
indent : Int,
) -> YamlValue {
self.skip_ignorable()
if self.idx >= self.lines.length() {
return YamlValue::Null
}
if is_sequence_item(self.lines[self.idx].text) {
self.parse_sequence(indent)
} else {
self.parse_mapping(indent)
}
}
///|
fn parse_yaml_document(text : String) -> (YamlValue, Array[String]) {
let parser = yaml_parser_new(text)
parser.skip_ignorable()
if parser.idx >= parser.lines.length() {
return (YamlValue::Obj([]), parser.errors)
}
let root = parser.parse_block(parser.lines[parser.idx].indent)
(root, parser.errors)
}
///|
fn yaml_obj_get(fields : Array[YamlField], key : String) -> YamlValue? {
let mut found : YamlValue? = None
for field in fields {
if field.key == key {
found = Some(field.value)
}
}
found
}
///|
fn yaml_as_obj(
value : YamlValue,
errors : Array[String],
ctx : String,
) -> Array[YamlField] {
match value {
Obj(fields) => fields
_ => {
errors.push(ctx + " must be a mapping")
[]
}
}
}
///|
fn yaml_as_optional_string(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> String? {
match value {
Some(Str(text)) => Some(text)
Some(Null) => None
Some(_) => {
errors.push(ctx + " must be a string")
None
}
None => None
}
}
///|
fn yaml_as_string_list(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Array[String] {
match value {
Some(Str(text)) => [text]
Some(Seq(items)) => {
let result : Array[String] = []
for item in items {
match item {
Str(text) => result.push(text)
_ => errors.push(ctx + " items must be strings")
}
}
result
}
Some(Null) => []
Some(_) => {
errors.push(ctx + " must be a string or list")
[]
}
None => []
}
}
///|
fn yaml_to_string_map(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Map[String, String] {
let result : Map[String, String] = {}
guard value is Some(actual) else { return result }
match actual {
Obj(fields) =>
for field in fields {
match field.value {
Str(text) => result[field.key] = text
Null => result[field.key] = ""
_ => errors.push(ctx + "." + field.key + " must be a string")
}
}
_ => errors.push(ctx + " must be a mapping")
}
result
}
///|
fn yaml_as_optional_bool(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Bool? {
match value {
Some(Str(text)) => {
let normalized = text.trim(chars=" \t\r\n").to_lower()
if normalized == "true" {
Some(true)
} else if normalized == "false" {
Some(false)
} else {
errors.push(ctx + " must be true or false")
None
}
}
Some(Null) => None
Some(_) => {
errors.push(ctx + " must be a boolean")
None
}
None => None
}
}
///|
#warnings("-deprecated")
fn parser_parse_int64(value : StringView) -> Int64 raise {
@strconv.parse_int64(value)
}
///|
fn yaml_as_optional_int(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Int? {
match value {
Some(Str(text)) => {
let trimmed = text.trim(chars=" \t\r\n").to_owned()
if trimmed.length() == 0 {
return None
}
let parsed = parser_parse_int64(trimmed) catch {
_ => {
errors.push(ctx + " must be an integer")
return None
}
}
Some(parsed.to_int())
}
Some(Null) => None
Some(_) => {
errors.push(ctx + " must be an integer")
None
}
None => None
}
}
///|
fn yaml_as_string_map_list(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Array[Map[String, String]] {
let result : Array[Map[String, String]] = []
guard value is Some(actual) else { return result }
match actual {
Seq(items) => {
let mut idx = 0
while idx < items.length() {
result.push(
yaml_to_string_map(
Some(items[idx]),
errors,
ctx + "[" + idx.to_string() + "]",
),
)
idx += 1
}
}
Null => ()
_ => errors.push(ctx + " must be a list")
}
result
}
///|
fn matrix_product_rows(
axes : Array[(String, Array[String])],
idx : Int,
current : Map[String, String],
rows : Array[Map[String, String]],
) -> Unit {
if idx >= axes.length() {
let row : Map[String, String] = {}
for key, value in current {
row[key] = value
}
rows.push(row)
return
}
let (key, values) = axes[idx]
for value in values {
let next : Map[String, String] = {}
for existing_key, existing_value in current {
next[existing_key] = existing_value
}
next[key] = value
matrix_product_rows(axes, idx + 1, next, rows)
}
}
///|
fn matrix_row_matches(
row : Map[String, String],
pattern : Map[String, String],
) -> Bool {
for key, value in pattern {
match row.get(key) {
Some(actual) if actual == value => ()
_ => return false
}
}
true
}
///|
fn matrix_row_merge(
base : Map[String, String],
overlay : Map[String, String],
) -> Map[String, String] {
let merged : Map[String, String] = {}
for key, value in base {
merged[key] = value
}
for key, value in overlay {
merged[key] = value
}
merged
}
///|
fn matrix_include_matches_original(
original_row : Map[String, String],
include_row : Map[String, String],
) -> Bool {
for key, value in include_row {
match original_row.get(key) {
Some(actual) if actual == value => ()
Some(_) => return false
None => ()
}
}
true
}
///|
fn expand_matrix_includes(
original_rows : Array[Map[String, String]],
include_rows : Array[Map[String, String]],
) -> Array[Map[String, String]] {
let expanded : Array[Map[String, String]] = []
for row in original_rows {
expanded.push(matrix_row_merge({}, row))
}
for include_row in include_rows {
let mut matched = false
let mut row_index = 0
while row_index < original_rows.length() {
if matrix_include_matches_original(original_rows[row_index], include_row) {
expanded[row_index] = matrix_row_merge(expanded[row_index], include_row)
matched = true
}
row_index += 1
}
if !matched {
expanded.push(matrix_row_merge({}, include_row))
}
}
expanded
}
///|
fn filter_matrix_excludes(
rows : Array[Map[String, String]],
excludes : Array[Map[String, String]],
) -> Array[Map[String, String]] {
if excludes.length() == 0 {
return rows
}
let filtered : Array[Map[String, String]] = []
for row in rows {
let mut excluded = false
for pattern in excludes {
if matrix_row_matches(row, pattern) {
excluded = true
break
}
}
if !excluded {
filtered.push(row)
}
}
filtered
}
///|
fn parse_job_matrix(
strategy_value : YamlValue?,
errors : Array[String],
ctx : String,
) -> JobMatrixSpec? {
guard strategy_value is Some(actual_strategy) else { return None }
let strategy_fields = yaml_as_obj(actual_strategy, errors, ctx)
let matrix_value = yaml_obj_get(strategy_fields, "matrix")
guard matrix_value is Some(actual_matrix) else { return None }
let matrix_fields = yaml_as_obj(actual_matrix, errors, ctx + ".matrix")
let axes : Array[(String, Array[String])] = []
let mut include_rows : Array[Map[String, String]] = []
let mut exclude_rows : Array[Map[String, String]] = []
for field in matrix_fields {
if field.key == "include" {
include_rows = yaml_as_string_map_list(
Some(field.value),
errors,
ctx + ".matrix.include",
)
continue
}
if field.key == "exclude" {
exclude_rows = yaml_as_string_map_list(
Some(field.value),
errors,
ctx + ".matrix.exclude",
)
continue
}
match field.value {
Seq(_) | Str(_) | Null =>
axes.push(
(
field.key,
yaml_as_string_list(
Some(field.value),
errors,
ctx + ".matrix." + field.key,
),
),
)
_ =>
errors.push(ctx + ".matrix." + field.key + " must be a string or list")
}
}
let mut rows : Array[Map[String, String]] = []
if axes.length() > 0 {
let current : Map[String, String] = {}
matrix_product_rows(axes, 0, current, rows)
rows = filter_matrix_excludes(rows, exclude_rows)
rows = expand_matrix_includes(rows, include_rows)
} else {
rows = include_rows
rows = filter_matrix_excludes(rows, exclude_rows)
}
if rows.length() == 0 {
errors.push(ctx + ".matrix must define at least one row")
return None
}
let fail_fast = yaml_as_optional_bool(
yaml_obj_get(strategy_fields, "fail-fast"),
errors,
ctx + ".fail-fast",
).unwrap_or(true)
let max_parallel = yaml_as_optional_int(
yaml_obj_get(strategy_fields, "max-parallel"),
errors,
ctx + ".max-parallel",
)
if max_parallel is Some(value) && value <= 0 {
errors.push(ctx + ".max-parallel must be positive")
return None
}
Some(new_job_matrix(rows, fail_fast~, max_parallel~))
}
///|
fn parse_run_defaults(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> RunDefaults {
guard value is Some(actual) else { return new_run_defaults() }
let fields = yaml_as_obj(actual, errors, ctx)
let run_fields = match yaml_obj_get(fields, "run") {
Some(run_value) => yaml_as_obj(run_value, errors, ctx + ".run")
None => []
}
new_run_defaults(
shell=yaml_as_optional_string(
yaml_obj_get(run_fields, "shell"),
errors,
ctx + ".run.shell",
),
working_directory=yaml_as_optional_string(
yaml_obj_get(run_fields, "working-directory"),
errors,
ctx + ".run.working-directory",
),
)
}
///|
fn parse_permissions(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> PermissionsSpec? {
match value {
None => None
Some(Str(text)) => Some(new_permissions_spec(values={ "_": text }))
Some(Obj(fields)) =>
Some(
new_permissions_spec(
values=yaml_to_string_map(Some(Obj(fields)), errors, ctx),
),
)
Some(Null) => None
Some(_) => {
errors.push(ctx + " must be a string or mapping")
None
}
}
}
///|
fn parse_concurrency(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> ConcurrencySpec? {
match value {
None => None
Some(Str(text)) => Some(new_concurrency_spec(text))
Some(Obj(fields)) =>
Some(
new_concurrency_spec(
yaml_as_optional_string(
yaml_obj_get(fields, "group"),
errors,
ctx + ".group",
).unwrap_or(""),
cancel_in_progress=yaml_as_optional_string(
yaml_obj_get(fields, "cancel-in-progress"),
errors,
ctx + ".cancel-in-progress",
),
),
)
Some(Null) => None
Some(_) => {
errors.push(ctx + " must be a string or mapping")
None
}
}
}
///|
priv struct ParsedTriggers {
push : PushTrigger
pull_request : PushTrigger?
workflow_call : Bool
workflow_call_spec : WorkflowCallSpec?
}
///|
fn parse_workflow_call_trigger(
value : YamlValue?,
errors : Array[String],
) -> WorkflowCallSpec? {
fn parse_workflow_call_inputs(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Map[String, WorkflowCallInputSpec] {
let inputs : Map[String, WorkflowCallInputSpec] = {}
guard value is Some(actual) else { return inputs }
match actual {
Obj(fields) =>
for field in fields {
let input_fields = yaml_as_obj(
field.value,
errors,
ctx + "." + field.key,
)
let input_type = yaml_as_optional_string(
yaml_obj_get(input_fields, "type"),
errors,
ctx + "." + field.key + ".type",
).unwrap_or("")
if input_type.length() == 0 {
errors.push(ctx + "." + field.key + ".type is required")
}
if input_type.length() > 0 &&
input_type != "boolean" &&
input_type != "number" &&
input_type != "string" {
errors.push(
ctx + "." + field.key + ".type must be boolean, number, or string",
)
}
inputs[field.key] = new_workflow_call_input_spec(
description=yaml_as_optional_string(
yaml_obj_get(input_fields, "description"),
errors,
ctx + "." + field.key + ".description",
).unwrap_or(""),
required=yaml_as_optional_bool(
yaml_obj_get(input_fields, "required"),
errors,
ctx + "." + field.key + ".required",
).unwrap_or(false),
default_value=yaml_as_optional_string(
yaml_obj_get(input_fields, "default"),
errors,
ctx + "." + field.key + ".default",
),
input_type~,
)
}
_ => errors.push(ctx + " must be a mapping")
}
inputs
}
fn parse_workflow_call_outputs(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Map[String, WorkflowCallOutputSpec] {
let outputs : Map[String, WorkflowCallOutputSpec] = {}
guard value is Some(actual) else { return outputs }
match actual {
Obj(fields) =>
for field in fields {
let output_fields = yaml_as_obj(
field.value,
errors,
ctx + "." + field.key,
)
let value_expr = yaml_as_optional_string(
yaml_obj_get(output_fields, "value"),
errors,
ctx + "." + field.key + ".value",
).unwrap_or("")
if value_expr.length() == 0 {
errors.push(ctx + "." + field.key + ".value is required")
}
outputs[field.key] = new_workflow_call_output_spec(
description=yaml_as_optional_string(
yaml_obj_get(output_fields, "description"),
errors,
ctx + "." + field.key + ".description",
).unwrap_or(""),
value=value_expr,
)
}
_ => errors.push(ctx + " must be a mapping")
}
outputs
}
fn parse_workflow_call_secrets(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Map[String, WorkflowCallSecretSpec] {
let secrets : Map[String, WorkflowCallSecretSpec] = {}
guard value is Some(actual) else { return secrets }
match actual {
Obj(fields) =>
for field in fields {
let secret_fields = yaml_as_obj(
field.value,
errors,
ctx + "." + field.key,
)
secrets[field.key] = new_workflow_call_secret_spec(
required=yaml_as_optional_bool(
yaml_obj_get(secret_fields, "required"),
errors,
ctx + "." + field.key + ".required",
).unwrap_or(false),
)
}
_ => errors.push(ctx + " must be a mapping")
}
secrets
}
match value {
Some(Obj(fields)) =>
Some(
new_workflow_call_spec(
inputs=parse_workflow_call_inputs(
yaml_obj_get(fields, "inputs"),
errors,
"on.workflow_call.inputs",
),
outputs=parse_workflow_call_outputs(
yaml_obj_get(fields, "outputs"),
errors,
"on.workflow_call.outputs",
),
secrets=parse_workflow_call_secrets(
yaml_obj_get(fields, "secrets"),
errors,
"on.workflow_call.secrets",
),
),
)
Some(Null) => Some(new_workflow_call_spec())
Some(_) => {
errors.push("on.workflow_call must be a mapping")
None
}
None => None
}
}
///|
fn parse_push_trigger_value(
value : YamlValue,
errors : Array[String],
) -> PushTrigger {
match value {
Null => new_push_trigger()
Obj(fields) =>
new_push_trigger(
branches=yaml_as_string_list(
yaml_obj_get(fields, "branches"),
errors,
"on.push.branches",
),
branches_ignore=yaml_as_string_list(
yaml_obj_get(fields, "branches-ignore"),
errors,
"on.push.branches-ignore",
),
paths=yaml_as_string_list(
yaml_obj_get(fields, "paths"),
errors,
"on.push.paths",
),
paths_ignore=yaml_as_string_list(
yaml_obj_get(fields, "paths-ignore"),
errors,
"on.push.paths-ignore",
),
tags=yaml_as_string_list(
yaml_obj_get(fields, "tags"),
errors,
"on.push.tags",
),
tags_ignore=yaml_as_string_list(
yaml_obj_get(fields, "tags-ignore"),
errors,
"on.push.tags-ignore",
),
)
_ => {
errors.push("on.push must be a mapping")
new_push_trigger()
}
}
}
///|
fn parse_trigger(value : YamlValue?, errors : Array[String]) -> ParsedTriggers {
match value {
Some(Str(event_name)) =>
if event_name == "push" {
{
push: new_push_trigger(),
pull_request: None,
workflow_call: false,
workflow_call_spec: None,
}
} else if event_name == "pull_request" ||
event_name == "pull_request_target" {
{
push: new_push_trigger(),
pull_request: Some(new_push_trigger()),
workflow_call: false,
workflow_call_spec: None,
}
} else if event_name == "workflow_call" {
{
push: new_push_trigger(),
pull_request: None,
workflow_call: true,
workflow_call_spec: Some(new_workflow_call_spec()),
}
} else {
errors.push("only push and workflow_call triggers are supported in MVP")
{
push: new_push_trigger(),
pull_request: None,
workflow_call: false,
workflow_call_spec: None,
}
}
Some(Seq(items)) => {
let mut has_push = false
let mut has_pull_request = false
let mut workflow_call = false
for item in items {
if item is Str("push") {
has_push = true
}
if item is Str("pull_request") || item is Str("pull_request_target") {
has_pull_request = true
}
if item is Str("workflow_call") {
workflow_call = true
}
}
if has_push || has_pull_request || workflow_call {
{
push: new_push_trigger(),
pull_request: if has_pull_request {
Some(new_push_trigger())
} else {
None
},
workflow_call,
workflow_call_spec: if workflow_call {
Some(new_workflow_call_spec())
} else {
None
},
}
} else {
errors.push("only push and workflow_call triggers are supported in MVP")
{
push: new_push_trigger(),
pull_request: None,
workflow_call: false,
workflow_call_spec: None,
}
}
}
Some(Null) => {
errors.push("on must define push or workflow_call")
{
push: new_push_trigger(),
pull_request: None,
workflow_call: false,
workflow_call_spec: None,
}
}
None => {
errors.push("on is required")
{
push: new_push_trigger(),
pull_request: None,
workflow_call: false,
workflow_call_spec: None,
}
}
Some(Obj(fields)) => {
let push = match yaml_obj_get(fields, "push") {
Some(push_value) => parse_push_trigger_value(push_value, errors)
None => new_push_trigger()
}
let pull_request : PushTrigger? = match
yaml_obj_get(fields, "pull_request") {
Some(pr_value) => Some(parse_push_trigger_value(pr_value, errors))
None =>
match yaml_obj_get(fields, "pull_request_target") {
Some(prt_value) => Some(parse_push_trigger_value(prt_value, errors))
None => None
}
}
let workflow_call = parse_workflow_call_trigger(
yaml_obj_get(fields, "workflow_call"),
errors,
)
let has_push_like = yaml_obj_get(fields, "push") is Some(_) ||
yaml_obj_get(fields, "pull_request") is Some(_) ||
yaml_obj_get(fields, "pull_request_target") is Some(_)
if !has_push_like && workflow_call is None {
errors.push("only push and workflow_call triggers are supported in MVP")
}
{
push,
pull_request,
workflow_call: workflow_call is Some(_),
workflow_call_spec: workflow_call,
}
}
}
}
///|
fn parse_step(
value : YamlValue,
errors : Array[String],
ctx : String,
) -> StepSpec {
let fields = yaml_as_obj(value, errors, ctx)
{
id: yaml_as_optional_string(yaml_obj_get(fields, "id"), errors, ctx + ".id").unwrap_or(
"",
),
name: yaml_as_optional_string(
yaml_obj_get(fields, "name"),
errors,
ctx + ".name",
).unwrap_or(""),
run: yaml_as_optional_string(
yaml_obj_get(fields, "run"),
errors,
ctx + ".run",
),
uses: yaml_as_optional_string(
yaml_obj_get(fields, "uses"),
errors,
ctx + ".uses",
),
shell: yaml_as_optional_string(
yaml_obj_get(fields, "shell"),
errors,
ctx + ".shell",
),
working_directory: yaml_as_optional_string(
yaml_obj_get(fields, "working-directory"),
errors,
ctx + ".working-directory",
),
env: yaml_to_string_map(yaml_obj_get(fields, "env"), errors, ctx + ".env"),
with_values: yaml_to_string_map(
yaml_obj_get(fields, "with"),
errors,
ctx + ".with",
),
if_condition: yaml_as_optional_string(
yaml_obj_get(fields, "if"),
errors,
ctx + ".if",
).unwrap_or("success()"),
continue_on_error: yaml_as_optional_string(
yaml_obj_get(fields, "continue-on-error"),
errors,
ctx + ".continue-on-error",
).unwrap_or("false"),
timeout_minutes: yaml_as_optional_int(
yaml_obj_get(fields, "timeout-minutes"),
errors,
ctx + ".timeout-minutes",
).unwrap_or(0),
}
}
///|
fn parse_job_steps(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Array[StepSpec] {
let steps : Array[StepSpec] = []
guard value is Some(actual) else {
errors.push(ctx + " is required")
return steps
}
match actual {
Seq(items) => {
let mut idx = 0
while idx < items.length() {
steps.push(
parse_step(items[idx], errors, ctx + "[" + idx.to_string() + "]"),
)
idx += 1
}
}
_ => errors.push(ctx + " must be a list")
}
steps
}
///|
fn parse_job_steps_optional(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Array[StepSpec] {
guard value is Some(actual) else { return [] }
parse_job_steps(Some(actual), errors, ctx)
}
///|
fn parse_container_credentials(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> JobContainerCredentialsSpec? {
guard value is Some(actual) else { return None }
let fields = yaml_as_obj(actual, errors, ctx)
let username = yaml_as_optional_string(
yaml_obj_get(fields, "username"),
errors,
ctx + ".username",
).unwrap_or("")
let password = yaml_as_optional_string(
yaml_obj_get(fields, "password"),
errors,
ctx + ".password",
).unwrap_or("")
if username.length() == 0 {
errors.push(ctx + ".username is required")
}
if password.length() == 0 {
errors.push(ctx + ".password is required")
}
Some(new_job_container_credentials_spec(username, password))
}
///|
fn parse_job_container_spec(
value : YamlValue?,
errors : Array[String],
ctx : String,
image_required : Bool,
) -> JobContainerSpec? {
match value {
Some(Str(text)) => Some(new_job_container_spec(text))
Some(Obj(fields)) => {
let image = yaml_as_optional_string(
yaml_obj_get(fields, "image"),
errors,
ctx + ".image",
).unwrap_or("")
if image_required && image.length() == 0 {
errors.push(ctx + ".image is required")
}
Some(
new_job_container_spec(
image,
credentials=parse_container_credentials(
yaml_obj_get(fields, "credentials"),
errors,
ctx + ".credentials",
),
env=yaml_to_string_map(
yaml_obj_get(fields, "env"),
errors,
ctx + ".env",
),
ports=yaml_as_string_list(
yaml_obj_get(fields, "ports"),
errors,
ctx + ".ports",
),
volumes=yaml_as_string_list(
yaml_obj_get(fields, "volumes"),
errors,
ctx + ".volumes",
),
options=yaml_as_optional_string(
yaml_obj_get(fields, "options"),
errors,
ctx + ".options",
),
),
)
}
Some(Null) => None
Some(_) => {
errors.push(ctx + " must be a string or mapping")
None
}
None => None
}
}
///|
fn parse_container(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> JobContainerSpec? {
parse_job_container_spec(value, errors, ctx, true)
}
///|
fn parse_services(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Map[String, JobContainerSpec] {
let services : Map[String, JobContainerSpec] = {}
guard value is Some(actual) else { return services }
match actual {
Obj(fields) =>
for field in fields {
match
parse_job_container_spec(
Some(field.value),
errors,
ctx + "." + field.key,
false,
) {
Some(service) => services[field.key] = service
None => ()
}
}
_ => errors.push(ctx + " must be a mapping")
}
services
}
///|
fn parse_reusable_workflow_secrets(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> (Map[String, String], Bool) {
guard value is Some(actual) else { return ({}, false) }
match actual {
Str(text) =>
if text.trim(chars=" \t\r\n").to_lower() == "inherit" {
({}, true)
} else {
errors.push(ctx + " must be a mapping or 'inherit'")
({}, false)
}
Obj(_) => (yaml_to_string_map(Some(actual), errors, ctx), false)
_ => {
errors.push(ctx + " must be a mapping or 'inherit'")
({}, false)
}
}
}
///|
fn parse_environment(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> String {
guard value is Some(actual) else { return "" }
match actual {
Str(name) => name
Obj(fields) =>
yaml_as_optional_string(
yaml_obj_get(fields, "name"),
errors,
ctx + ".name",
).unwrap_or("")
Null => ""
_ => {
errors.push(ctx + " must be a string or mapping")
""
}
}
}
///|
fn parse_job(id : String, value : YamlValue, errors : Array[String]) -> JobSpec {
let ctx = "jobs." + id
let fields = yaml_as_obj(value, errors, ctx)
let strategy_value = yaml_obj_get(fields, "strategy")
let reusable_workflow = yaml_as_optional_string(
yaml_obj_get(fields, "uses"),
errors,
ctx + ".uses",
)
let container = parse_container(
yaml_obj_get(fields, "container"),
errors,
ctx + ".container",
)
let reusable_workflow_with = yaml_to_string_map(
yaml_obj_get(fields, "with"),
errors,
ctx + ".with",
)
let (reusable_workflow_secrets, reusable_workflow_inherit_secrets) = parse_reusable_workflow_secrets(
yaml_obj_get(fields, "secrets"),
errors,
ctx + ".secrets",
)
new_job(
id,
if reusable_workflow is Some(_) {
parse_job_steps_optional(
yaml_obj_get(fields, "steps"),
errors,
ctx + ".steps",
)
} else {
parse_job_steps(yaml_obj_get(fields, "steps"), errors, ctx + ".steps")
},
name=yaml_as_optional_string(
yaml_obj_get(fields, "name"),
errors,
ctx + ".name",
).unwrap_or(""),
if_condition=yaml_as_optional_string(
yaml_obj_get(fields, "if"),
errors,
ctx + ".if",
).unwrap_or("success()"),
needs=yaml_as_string_list(
yaml_obj_get(fields, "needs"),
errors,
ctx + ".needs",
),
outputs=yaml_to_string_map(
yaml_obj_get(fields, "outputs"),
errors,
ctx + ".outputs",
),
permissions=parse_permissions(
yaml_obj_get(fields, "permissions"),
errors,
ctx + ".permissions",
),
concurrency=parse_concurrency(
yaml_obj_get(fields, "concurrency"),
errors,
ctx + ".concurrency",
),
runs_on=yaml_as_string_list(
yaml_obj_get(fields, "runs-on"),
errors,
ctx + ".runs-on",
),
env=yaml_to_string_map(yaml_obj_get(fields, "env"), errors, ctx + ".env"),
defaults=parse_run_defaults(
yaml_obj_get(fields, "defaults"),
errors,
ctx + ".defaults",
),
matrix=parse_job_matrix(strategy_value, errors, ctx + ".strategy"),
reusable_workflow~,
reusable_workflow_with~,
reusable_workflow_secrets~,
reusable_workflow_inherit_secrets~,
container~,
services=parse_services(
yaml_obj_get(fields, "services"),
errors,
ctx + ".services",
),
timeout_minutes=yaml_as_optional_int(
yaml_obj_get(fields, "timeout-minutes"),
errors,
ctx + ".timeout-minutes",
).unwrap_or(0),
environment=parse_environment(
yaml_obj_get(fields, "environment"),
errors,
ctx + ".environment",
),
)
}
///|
fn parse_local_action_inputs(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Map[String, String] {
let inputs : Map[String, String] = {}
guard value is Some(actual) else { return inputs }
match actual {
Obj(fields) =>
for field in fields {
match field.value {
Obj(input_fields) =>
inputs[field.key] = yaml_as_optional_string(
yaml_obj_get(input_fields, "default"),
errors,
ctx + "." + field.key + ".default",
).unwrap_or("")
Str(text) => inputs[field.key] = text
Null => inputs[field.key] = ""
_ =>
errors.push(ctx + "." + field.key + " must be a mapping or string")
}
}
_ => errors.push(ctx + " must be a mapping")
}
inputs
}
///|
fn parse_action_outputs(
value : YamlValue?,
errors : Array[String],
ctx : String,
) -> Map[String, String] {
let outputs : Map[String, String] = {}
guard value is Some(actual) else { return outputs }
match actual {
Obj(fields) =>
for field in fields {
match field.value {
Obj(output_fields) =>
outputs[field.key] = yaml_as_optional_string(
yaml_obj_get(output_fields, "value"),
errors,
ctx + "." + field.key + ".value",
).unwrap_or("")
Str(text) => outputs[field.key] = text
Null => outputs[field.key] = ""
_ =>
errors.push(ctx + "." + field.key + " must be a mapping or string")
}
}
_ => errors.push(ctx + " must be a mapping")
}
outputs
}
///|
priv struct ActionManifestSpec {
name : String
inputs : Map[String, String]
outputs : Map[String, String]
runtime : String
steps : Array[StepSpec]
main : String
pre : String?
post : String?
pre_if : String
post_if : String
image : String
args : Array[String]
entrypoint : String?
pre_entrypoint : String?
post_entrypoint : String?
}
///|
priv struct ActionManifestParseResult {
action : ActionManifestSpec?
errors : Array[String]
}
///|
/// Convert moonbit-community/yaml Yaml to internal YamlValue
fn yaml_to_internal(yaml : @yaml.Yaml) -> YamlValue {
match yaml {
@yaml.Yaml::String(s) => YamlValue::Str(s)
@yaml.Yaml::Integer(n) => YamlValue::Str(n.to_string())
@yaml.Yaml::Real(_, repr~) => YamlValue::Str(repr)
@yaml.Yaml::Boolean(b) => YamlValue::Str(if b { "true" } else { "false" })
@yaml.Yaml::Null | @yaml.Yaml::BadValue => YamlValue::Null
@yaml.Yaml::Array(items) => {
let result : Array[YamlValue] = []
for item in items {
result.push(yaml_to_internal(item))
}
YamlValue::Seq(result)
}
@yaml.Yaml::Map(entries) => {
let fields : Array[YamlField] = []
for key, value in entries {
fields.push({ key, value: yaml_to_internal(value) })
}
YamlValue::Obj(fields)
}
}
}
///|
fn parse_action_manifest_yaml(text : String) -> ActionManifestParseResult {
// Use moonbit-community/yaml for robust YAML parsing
// (handles multiline single-quoted strings, etc.)
let errors : Array[String] = []
let yaml = try {
let docs = @yaml.Yaml::load_from_string(text)
if docs.length() > 0 {
yaml_to_internal(docs[0])
} else {
YamlValue::Null
}
} catch {
err => {
errors.push("YAML parse error: " + err.to_string())
return { action: None, errors }
}
}
guard yaml is Obj(root_fields) else {
errors.push("local action root must be a mapping")
return { action: None, errors }
}
let name = yaml_as_optional_string(
yaml_obj_get(root_fields, "name"),
errors,
"name",
).unwrap_or("")
let inputs = parse_local_action_inputs(
yaml_obj_get(root_fields, "inputs"),
errors,
"inputs",
)
let outputs = parse_action_outputs(
yaml_obj_get(root_fields, "outputs"),
errors,
"outputs",
)
let runs_fields = match yaml_obj_get(root_fields, "runs") {
Some(runs_value) => yaml_as_obj(runs_value, errors, "runs")
None => {
errors.push("runs is required")
[]
}
}
let action_using = yaml_as_optional_string(
yaml_obj_get(runs_fields, "using"),
errors,
"runs.using",
).unwrap_or("")
if action_using.length() == 0 {
errors.push("runs.using is required")
}
let main = yaml_as_optional_string(
yaml_obj_get(runs_fields, "main"),
errors,
"runs.main",
).unwrap_or("")
let pre = yaml_as_optional_string(
yaml_obj_get(runs_fields, "pre"),
errors,
"runs.pre",
)
let post = yaml_as_optional_string(
yaml_obj_get(runs_fields, "post"),
errors,
"runs.post",
)
let pre_if = yaml_as_optional_string(
yaml_obj_get(runs_fields, "pre-if"),
errors,
"runs.pre-if",
).unwrap_or("always()")
let post_if = yaml_as_optional_string(
yaml_obj_get(runs_fields, "post-if"),
errors,
"runs.post-if",
).unwrap_or("always()")
let image = yaml_as_optional_string(
yaml_obj_get(runs_fields, "image"),
errors,
"runs.image",
).unwrap_or("")
let args = yaml_as_string_list(
yaml_obj_get(runs_fields, "args"),
errors,
"runs.args",
)
let entrypoint = yaml_as_optional_string(
yaml_obj_get(runs_fields, "entrypoint"),
errors,
"runs.entrypoint",
)
let pre_entrypoint = yaml_as_optional_string(
yaml_obj_get(runs_fields, "pre-entrypoint"),
errors,
"runs.pre-entrypoint",
)
let post_entrypoint = yaml_as_optional_string(
yaml_obj_get(runs_fields, "post-entrypoint"),
errors,
"runs.post-entrypoint",
)
let steps = if action_using == "composite" {
parse_job_steps(yaml_obj_get(runs_fields, "steps"), errors, "runs.steps")
} else {
[]
}
if action_using == "composite" && steps.length() == 0 {
errors.push("runs.steps must have at least one step")
}
if action_using.has_prefix("node") && main.length() == 0 {
errors.push("runs.main is required")
}
if action_using == "docker" && image.length() == 0 {
errors.push("runs.image is required")
}
if pre_if != "always()" && pre_if != "success()" && pre_if != "!cancelled()" {
errors.push(
"runs.pre-if must be success(), always(), or !cancelled() in MVP",
)
}
if post_if != "always()" &&
post_if != "success()" &&
post_if != "!cancelled()" {
errors.push(
"runs.post-if must be success(), always(), or !cancelled() in MVP",
)
}
if errors.length() > 0 {
{ action: None, errors }
} else {
{
action: Some({
name,
inputs,
outputs,
runtime: action_using,
steps,
main,
pre,
post,
pre_if,
post_if,
image,
args,
entrypoint,
pre_entrypoint,
post_entrypoint,
}),
errors,
}
}
}
///|
pub fn parse_local_action_yaml(text : String) -> LocalActionParseResult {
let parsed = parse_action_manifest_yaml(text)
guard parsed.action is Some(action) else {
return { action: None, errors: parsed.errors }
}
let errors : Array[String] = []
for err in parsed.errors {
errors.push(err)
}
if action.runtime != "composite" {
errors.push("only composite local actions are supported in MVP")
}
if errors.length() > 0 {
{ action: None, errors }
} else {
{
action: Some(
new_local_action(
action.steps,
name=action.name,
inputs=action.inputs,
outputs=action.outputs,
),
),
errors,
}
}
}
///|
pub fn parse_workflow_yaml(text : String) -> WorkflowParseResult {
let (yaml, parser_errors) = parse_yaml_document(text)
let errors : Array[String] = []
for err in parser_errors {
errors.push(err)
}
guard yaml is Obj(root_fields) else {
errors.push("workflow root must be a mapping")
return { workflow: None, errors }
}
let jobs_value = yaml_obj_get(root_fields, "jobs")
let jobs : Array[JobSpec] = []
match jobs_value {
Some(Obj(job_fields)) =>
for field in job_fields {
jobs.push(parse_job(field.key, field.value, errors))
}
Some(_) => errors.push("jobs must be a mapping")
None => errors.push("jobs is required")
}
let parsed_trigger = parse_trigger(yaml_obj_get(root_fields, "on"), errors)
let workflow = new_workflow(
yaml_as_optional_string(yaml_obj_get(root_fields, "name"), errors, "name").unwrap_or(
"",
),
jobs,
run_name=yaml_as_optional_string(
yaml_obj_get(root_fields, "run-name"),
errors,
"run-name",
).unwrap_or(""),
trigger=parsed_trigger.push,
pull_request_trigger=parsed_trigger.pull_request,
workflow_call=parsed_trigger.workflow_call,
workflow_call_spec=parsed_trigger.workflow_call_spec,
permissions=parse_permissions(
yaml_obj_get(root_fields, "permissions"),
errors,
"permissions",
),
concurrency=parse_concurrency(
yaml_obj_get(root_fields, "concurrency"),
errors,
"concurrency",
),
env=yaml_to_string_map(yaml_obj_get(root_fields, "env"), errors, "env"),
defaults=parse_run_defaults(
yaml_obj_get(root_fields, "defaults"),
errors,
"defaults",
),
)
if errors.length() > 0 {
{ workflow: None, errors }
} else {
{ workflow: Some(workflow), errors }
}
}