Align trigger preflight with released workflow API #16

Closed
day01 wants to merge 7 commits from feat/0.6-trigger-preflight-main into develop
57 changed files with 2885 additions and 199 deletions
Showing only changes of commit 12ca789030 - Show all commits
-3
View File
@@ -1,16 +1,13 @@
root = true

[*]
indent_style = space
indent_size = 2
tab_width = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.rs]
indent_size = 4

[Makefile]
indent_style = tab
root = true

[*]
indent_style = space
indent_size = 2
tab_width = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.rs]
indent_size = 4
+13 -10
View File
@@ -1,80 +1,83 @@
# Repository Instructions

## Purpose

This repository holds the SynCode workflow compiler shared by `syncode/control`
and `syncode/runner`. A workflow is compiled exactly once, in the control plane,
and the compiled execution plan is the assignment contract. Both products
consume the same crates so they cannot drift in how they read the same file.

The compilation side lives here: the neutral compiler port, HIR, the plan model,
strategy, workflow reading and model, expressions with their AST and functions,
contexts, and the action model and references. The execution side stays in the
runner: sandbox, worker, cache, source fetching, and the plan executor.

## Language

Write everything in English: source code, tests, errors, logs, documentation,
configuration examples, CI messages, commits, and pull requests.

## Implementation

- Use stable Rust and idiomatic Rust.
- Model finite domain states with enums and distinct values with newtypes.
- Make invalid states unrepresentable through private fields, validated
constructors, and exhaustive pattern matching.
- Use standard traits such as `FromStr`, `TryFrom`, `From`, `Display`, `AsRef`,
`IntoIterator`, and `Error` for parsing, conversion, rendering, and
composition. Do not replace an applicable standard trait with ad hoc
`parse_*`, `to_string`, string dispatch, or conversion helpers.
- Keep raw strings at serialization, protocol, configuration, and workflow
syntax boundaries. Convert them into typed domain values immediately.
- Use `thiserror` for typed errors. Do not use `anyhow`.
- Reject unsupported workflow features explicitly. Never add silent fallbacks.
- Do not ignore errors or warnings.
- Do not use `unwrap` or `expect` in production code unless an invariant makes
failure impossible and the invariant is enforced locally.
- Avoid `unsafe`.

## Boundaries

Compilation is pure. `syncode-workflow` carries no serialization, no async
runtime, and no transport: `check-architecture.sh` rejects `serde`, `tokio`,
`reqwest`, `prost`, `gix`, and `bollard`. The wire representation of the plan is
a separate type owned by the protocol boundary, not a serialization of this
model.

Every crate manifest here is self-contained: it must not inherit from
`workspace.package` and must not set `package.workspace`. Consumers vendor this
repository as a submodule and depend on the crates by path, which makes them
members of the consumer workspace; inheritance would then resolve against the
wrong root.

## File Size

Rust source files must not exceed 250 physical lines. Split a file before it
crosses the limit.

## Verification

Before handing off changes, run:

```sh
cargo fmt --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
./scripts/check-architecture.sh
./scripts/check-rust-loc.sh
```

A change is not complete until its behavior has been exercised. Because both
products embed this repository, also build the consumer that depends on the
change before handing off.

## Git

- Open every pull request against `develop`. `main` carries what production
runs, so it only receives releases merged from `develop` and hotfixes.
- Use Conventional Commits in imperative English.
- Never add commit trailers.
- Never force-push, amend, or squash unless explicitly requested.
# Repository Instructions

## Purpose

This repository holds the SynCode workflow compiler shared by `syncode/control`
and `syncode/runner`. A workflow is compiled exactly once, in the control plane,
and the compiled execution plan is the assignment contract. Both products
consume the same crates so they cannot drift in how they read the same file.

The compilation side lives here: the neutral compiler port, HIR, the plan model,
strategy, workflow reading and model, expressions with their AST and functions,
contexts, and the action model and references. The execution side stays in the
runner: sandbox, worker, cache, source fetching, and the plan executor.

## Language

Write everything in English: source code, tests, errors, logs, documentation,
configuration examples, CI messages, commits, and pull requests.

## Implementation

- Use stable Rust and idiomatic Rust.
- Model finite domain states with enums and distinct values with newtypes.
- Make invalid states unrepresentable through private fields, validated
constructors, and exhaustive pattern matching.
- Use standard traits such as `FromStr`, `TryFrom`, `From`, `Display`, `AsRef`,
`IntoIterator`, and `Error` for parsing, conversion, rendering, and
composition. Do not replace an applicable standard trait with ad hoc
`parse_*`, `to_string`, string dispatch, or conversion helpers.
- Keep raw strings at serialization, protocol, configuration, and workflow
syntax boundaries. Convert them into typed domain values immediately.
- Use `thiserror` for typed errors. Do not use `anyhow`.
- Reject unsupported workflow features explicitly. Never add silent fallbacks.
- Do not ignore errors or warnings.
- Do not use `unwrap` or `expect` in production code unless an invariant makes
failure impossible and the invariant is enforced locally.
- Avoid `unsafe`.

## Boundaries

Compilation is pure: no async runtime, no transport, no forge client.
`check-architecture.sh` rejects `tokio`, `reqwest`, `prost`, `gix`, and
`bollard`.

The plan travels as itself. There is no parallel wire model: the domain model
carries `serde`, and a plan on the wire is that model serialized rather than a
copy of it converted back and forth. Choosing an encoding — `serde_json` or
anything else — belongs to the protocol boundary and stays out of these crates.

Expressions travel as source and are rebuilt with `FromStr`; a syntax tree
never leaves the process that parsed it. Validated types deserialize through
their own constructors, so a plan that arrives cannot hold a value compilation
would have rejected. `VersionedPlan` carries the schema version and refuses to
decode one this build does not speak.

## File Size

Rust source files must not exceed 250 physical lines. Split a file before it
crosses the limit.

## Verification

Before handing off changes, run:

```sh
cargo fmt --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
./scripts/check-architecture.sh
./scripts/check-rust-loc.sh
```

A change is not complete until its behavior has been exercised. Because both
products embed this repository, also build the consumer that depends on the
change before handing off.

## Git

- Open every pull request against `develop`. `main` carries what production
runs, so it only receives releases merged from `develop` and hotfixes.
- Use Conventional Commits in imperative English.
- Never add commit trailers.
- Never force-push, amend, or squash unless explicitly requested.
+296 -3
View File
@@ -1,239 +1,532 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"

[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"

[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"

[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]

[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown",
]

[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"

[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"

[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]

[[package]]
name = "ordered-float"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
dependencies = [
"num-traits",
]

[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"

[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]

[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]

[[package]]
name = "saphyr"
version = "0.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acd6cfc4803660d26a3fb5bd8f5e47bc0eea5229f4d32f7cb4ee21a733e6961"
dependencies = [
"hashlink",
"ordered-float",
"saphyr-parser",
"thiserror",
]

[[package]]
name = "saphyr-parser"
version = "0.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebfd783fcf1b3f6bafd557be0e1427ec54f826f513c3cdd749f9844484df2a13"
dependencies = [
"arraydeque",
"thiserror",
]

[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]

[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]

[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]

[[package]]
name = "syncode-workflow"
version = "0.3.0"
dependencies = [
"thiserror",
]

[[package]]
name = "syncode-workflow-github-actions"
version = "0.3.0"
dependencies = [
"saphyr",
"serde_json",
"syncode-workflow",
"thiserror",
"tokio",
]

[[package]]
name = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
]

[[package]]
name = "thiserror-impl"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"pin-project-lite",
"tokio-macros",
]

[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"

[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"

[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"

[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]

[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"

[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]

[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]

[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown",
]

[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]

[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]

[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]

[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"

[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]

[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"

[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]

[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]

[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]

[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"

[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"

[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"

[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]

[[package]]
name = "ordered-float"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
dependencies = [
"num-traits",
]

[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"

[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"

[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]

[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]

[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]

[[package]]
name = "saphyr"
version = "0.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acd6cfc4803660d26a3fb5bd8f5e47bc0eea5229f4d32f7cb4ee21a733e6961"
dependencies = [
"hashlink",
"ordered-float",
"saphyr-parser",
"thiserror",
]

[[package]]
name = "saphyr-parser"
version = "0.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebfd783fcf1b3f6bafd557be0e1427ec54f826f513c3cdd749f9844484df2a13"
dependencies = [
"arraydeque",
"thiserror",
]

[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]

[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]

[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]

[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"

[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"

[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]

[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]

[[package]]
name = "syncode-workflow"
version = "0.3.0"
dependencies = [
"serde",
"thiserror",
"url",
]

[[package]]
name = "syncode-workflow-github-actions"
version = "0.3.0"
dependencies = [
"saphyr",
"serde",
"serde_json",
"syncode-workflow",
"thiserror",
"tokio",
]

[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]

[[package]]
name = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
]

[[package]]
name = "thiserror-impl"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]

[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]

[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"pin-project-lite",
"tokio-macros",
]

[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]

[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"

[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]

[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"

[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]

[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]

[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]

[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]

[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]

[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]

[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]

[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+15
View File
@@ -1,3 +1,18 @@
[workspace]
members = ["crates/workflow", "crates/workflow-github-actions"]
resolver = "3"
[workspace]
members = ["crates/workflow", "crates/workflow-github-actions"]
resolver = "3"

[workspace.package]
version = "0.3.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
repository = "https://syncode.sh/syncode/workflow"

[workspace.lints.rust]
unsafe_code = "forbid"

[workspace.lints.clippy]
expect_used = "deny"
panic = "deny"
unwrap_used = "deny"
-3
View File
@@ -1,24 +1,21 @@
MIT License

Copyright (c) 2026 The SynCode Authors

Portions Copyright (c) 2022 The Gitea Authors
Portions Copyright (c) GitHub, Inc. and contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
MIT License

Copyright (c) 2026 The SynCode Authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+16 -28
View File
@@ -1,57 +1,45 @@
# SynCode Workflow

Shared workflow compiler for SynCode. The control plane
([`syncode/control`](https://syncode.sh/syncode/control)) and the runner
([`syncode/runner`](https://syncode.sh/syncode/runner)) both build on these
crates so that one workflow file has exactly one interpretation.

## Why it is a separate repository

A workflow is compiled once, in the control plane. The runner receives a
versioned execution plan rather than the workflow file, so it never parses
workflow YAML. Keeping the compiler in one place is what makes that guarantee
hold: if each product carried its own copy, the plan a runner executes could
stop matching the plan the control plane believes it produced.

## Crates

| Crate | Contents |
| --- | --- |
| `syncode-workflow` | Neutral vocabulary: identifiers, templates, bindings, dynamic values, strategy |

The GitHub Actions frontend — workflow reading and model, expressions, contexts,
and the action model — moves here alongside it.

## Consuming the repository

Both products embed this repository as a git submodule at `workflow/` and depend
on the crates by path:

```toml
[dependencies]
syncode-workflow = { path = "../../workflow/crates/workflow" }
```

The crates then become members of the consumer workspace, so a consumer build
compiles and lints them together with its own code. This is why no manifest here
inherits from `workspace.package`.

Clone with the submodule from the consumer side:

```sh
git submodule update --init --recursive
```

## Development

```sh
cargo fmt --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
./scripts/check-architecture.sh
./scripts/check-rust-loc.sh
```

## License

MIT. See [LICENSE](LICENSE).
# SynCode Workflow

The workflow compiler shared by the SynCode control plane
([`syncode/control`](https://syncode.sh/syncode/control)) and the runner
([`syncode/runner`](https://syncode.sh/syncode/runner)).

A workflow is compiled once, in the control plane; the runner executes the
resulting plan and never parses workflow YAML. That holds only while both
products compile with the same code, which is why the compiler lives here
instead of inside either product.

## Crates

| Crate | Contents |
| --- | --- |
| `syncode-workflow` | Identifiers, templates, bindings, dynamic values, strategy, container definitions, HIR, plan model, compiler port |
| `syncode-workflow-github-actions` | GitHub Actions dialect: workflow reading and model, expressions, compiler frontend |

## Consuming

Depend on the crates by git URL pinned to a commit, so a consumer states exactly
which compiler it builds against:

```toml
[dependencies]
syncode-workflow = { git = "https://syncode.sh/syncode/workflow.git", rev = "<commit>" }
```

## Development

```sh
cargo fmt --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
./scripts/check-architecture.sh
./scripts/check-rust-loc.sh
```

Compilation is pure: `check-architecture.sh` keeps async runtimes, transports
and forge clients out of these crates. The plan model is serializable because
the plan travels as itself; choosing an encoding belongs to whoever carries it.

## License

MIT. See [LICENSE](LICENSE).
+1 -12
View File
@@ -1,49 +1,38 @@
#!/bin/sh
set -eu

runtime_dependencies() {
awk '
/^\[dependencies\]$/ { inside = 1; next }
/^\[/ { inside = 0 }
inside { print }
' "$1"
}

check_forbidden() {
manifest=$1
shift
section=$(runtime_dependencies "${manifest}")
for dependency in "$@"; do
if printf '%s\n' "${section}" | grep -Eq "^${dependency}[[:space:]]*=" ; then
echo "${manifest}: forbidden dependency ${dependency}" >&2
exit 1
fi
done
}

check_forbidden crates/workflow/Cargo.toml \
bollard gix prost prost-types reqwest serde serde_json tokio

check_forbidden crates/workflow-github-actions/Cargo.toml \
bollard gix prost prost-types reqwest tokio

if grep -R -E 'GITEA_' crates/workflow/src crates/workflow-github-actions/src >/dev/null; then
echo "the workflow compiler contains forge-specific environment names" >&2
exit 1
fi

if grep -R -E 'GITHUB_' crates/workflow/src >/dev/null; then
echo "the neutral crate contains dialect-specific environment names" >&2
exit 1
fi

for manifest in crates/*/Cargo.toml; do
if grep -Eq '\.workspace[[:space:]]*=' "${manifest}"; then
echo "${manifest}: must not inherit from a workspace root" >&2
exit 1
fi
if grep -Eq '^workspace[[:space:]]*=' "${manifest}"; then
echo "${manifest}: must not pin or inherit a workspace root" >&2
exit 1
fi
done
#!/bin/sh
set -eu

runtime_dependencies() {
awk '
/^\[dependencies\]$/ { inside = 1; next }
/^\[/ { inside = 0 }
inside { print }
' "$1"
}

check_forbidden() {
manifest=$1
shift
section=$(runtime_dependencies "${manifest}")
for dependency in "$@"; do
if printf '%s\n' "${section}" | grep -Eq "^${dependency}[[:space:]]*=" ; then
echo "${manifest}: forbidden dependency ${dependency}" >&2
exit 1
fi
done
}

check_forbidden crates/workflow/Cargo.toml \
bollard gix prost prost-types reqwest serde_json tokio

check_forbidden crates/workflow-github-actions/Cargo.toml \
bollard gix prost prost-types reqwest tokio

if grep -R -E 'GITEA_' crates/workflow/src crates/workflow-github-actions/src >/dev/null; then
echo "the workflow compiler contains forge-specific environment names" >&2
exit 1
fi

if grep -R -E 'GITHUB_' crates/workflow/src >/dev/null; then
echo "the neutral crate contains dialect-specific environment names" >&2
exit 1
fi
+13 -5
View File
@@ -1,23 +1,31 @@
name: pull-request

on:
pull_request:
paths-ignore:
- "**.md"
- "LICENSE"

concurrency:
group: workflow-pull-request-${{ github.event.number }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: docker/build-push-action@v6
with:
context: .
target: validation
outputs: type=cacheonly
name: pull-request

on:
pull_request:
paths-ignore:
- "**.md"
- "LICENSE"

concurrency:
group: workflow-pull-request-${{ github.event.number }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
container:
image: rust:1.95.0-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1
steps:
- uses: actions/checkout@v4

- run: rustup component add clippy rustfmt

- run: cargo fmt --check

- run: cargo clippy --workspace --all-targets --all-features -- -D warnings

- run: cargo test --workspace --all-targets --all-features

- run: ./scripts/check-architecture.sh

- run: ./scripts/check-rust-loc.sh
+8 -12
View File
@@ -1,26 +1,22 @@
[package]
name = "syncode-workflow-github-actions"
description = "GitHub Actions workflow frontend for SynCode"
version = "0.3.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
repository = "https://syncode.sh/syncode/workflow"
publish = false

[dependencies]
saphyr = { version = "0.0.11", default-features = false }
serde_json = "1.0.149"
syncode-workflow = { path = "../workflow" }
thiserror = "2.0.19"

[dev-dependencies]
tokio = { version = "1.49.0", features = ["macros", "rt"] }

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
expect_used = "deny"
panic = "deny"
unwrap_used = "deny"
[package]
name = "syncode-workflow-github-actions"
description = "GitHub Actions workflow frontend for SynCode"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
saphyr = { version = "0.0.11", default-features = false }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.149"
syncode-workflow = { path = "../workflow" }
thiserror = "2.0.19"

[dev-dependencies]
tokio = { version = "1.49.0", features = ["macros", "rt"] }

[lints]
workspace = true
+9 -12
View File
@@ -1,20 +1,17 @@
[package]
name = "syncode-workflow"
description = "Neutral SynCode workflow vocabulary shared by the control plane and the runner"
version = "0.3.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
repository = "https://syncode.sh/syncode/workflow"
publish = false

[dependencies]
thiserror = "2.0.19"

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
expect_used = "deny"
panic = "deny"
unwrap_used = "deny"
[package]
name = "syncode-workflow"
description = "Neutral SynCode workflow vocabulary shared by the control plane and the runner"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
serde = { version = "1.0.229", features = ["derive", "rc"] }
thiserror = "2.0.19"
url = "2.5.8"

[lints]
workspace = true
@@ -1,4 +1,5 @@
pub mod compiler;
pub mod expression;
pub mod template;
pub mod workflow;
pub mod action;
pub mod compiler;
pub mod expression;
pub mod template;
pub mod workflow;
@@ -1,222 +1,234 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use std::str::FromStr;

use syncode_workflow::{
DurationValue, EnvironmentKey, StepKind, TemplateSegment, WorkflowCompiler,
};
use syncode_workflow::{WorkflowDialect, WorkflowSource};
use syncode_workflow_github_actions::compiler::{GithubActionsCompileError, GithubActionsCompiler};
use syncode_workflow_github_actions::expression::{EvaluationContext, ExpressionProgram};

fn compile(
source: &str,
) -> Result<syncode_workflow::ExecutionPlan<ExpressionProgram>, GithubActionsCompileError> {
let hir = GithubActionsCompiler.compile(&WorkflowSource::new(
WorkflowDialect::GitHubActions,
source.as_bytes().to_vec(),
))?;
hir.try_into().map_err(Into::into)
}

#[test]
fn lowers_workflow_into_typed_execution_plan() {
let plan = compile(
r#"
name: CI
env:
TARGET: ${{ github.actor }}
defaults:
run:
shell: sh
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: test
if: success()
run: echo "${{ env.TARGET }}"
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(
plan.workflow_name().map(ToString::to_string).as_deref(),
Some("CI")
);
assert_eq!(plan.job().key().as_ref(), "build");
assert_eq!(plan.environment().len(), 1);
assert_eq!(plan.job().steps().len(), 1);
assert!(matches!(plan.job().steps()[0].kind(), StepKind::Shell(_)));
assert!(
plan.job().steps()[0]
.condition()
.evaluate_condition(&EvaluationContext::default())
.unwrap_or_else(|error| panic!("condition: {error}"))
);
}

#[test]
fn rejects_invalid_expression_during_compilation() {
let error = compile(
r#"
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: github.
run: echo invalid
"#,
)
.err()
.unwrap_or_else(|| panic!("invalid expression should fail"));

assert!(matches!(error, GithubActionsCompileError::Expression(_)));
}

#[test]
fn lowers_specialized_matrix_into_typed_context_values() {
let plan = compile(
r#"
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
rust: [stable]
target:
- os: linux
features: [tls, sqlite]
steps:
- run: echo "${{ matrix.rust }}"
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(
plan.job().matrix().property("rust"),
Some(&syncode_workflow::Value::String("stable".to_owned()))
);
assert!(matches!(
plan.job().matrix().property("target"),
Some(syncode_workflow::Value::Object(_))
));
}

#[test]
fn rejects_an_unspecialized_matrix_assignment() {
let error = compile(
r#"
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
rust: [stable, beta]
steps:
- run: echo invalid
"#,
)
.err()
.unwrap_or_else(|| panic!("unspecialized matrix should fail"));

assert!(matches!(error, GithubActionsCompileError::Field { .. }));
}

#[test]
fn compiles_outputs_and_timeouts_into_typed_plan_values() {
let plan = compile(
r#"
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
artifact: ${{ steps.package.outputs.name }}
steps:
- id: package
timeout-minutes: ${{ fromJSON('5') }}
run: echo package
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(plan.job().outputs().len(), 1);
assert!(matches!(
plan.job().timeout().value(),
DurationValue::Fixed(_)
));
assert!(matches!(
plan.job().steps()[0]
.timeout()
.map(syncode_workflow::Timeout::value),
Some(DurationValue::MinutesExpression(_))
));
}

#[test]
fn preserves_runner_selection_and_dependencies_in_execution_plan() {
let plan = compile(
r#"
jobs:
build:
needs: prepare
runs-on:
group: hosted
labels: [linux, "${{ inputs.arch }}"]
steps:
- run: echo build
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(
plan.job()
.needs()
.iter()
.map(AsRef::as_ref)
.collect::<Vec<_>>(),
["prepare"]
);
let runner = plan.job().runner();
assert!(matches!(
runner.group().and_then(|value| value.as_ref().first()),
Some(TemplateSegment::Literal(value)) if value == "hosted"
));
assert_eq!(runner.labels().len(), 2);
assert!(matches!(
runner.labels()[0].as_ref().first(),
Some(TemplateSegment::Literal(value)) if value == "linux"
));
assert!(matches!(
runner.labels()[1].as_ref(),
[TemplateSegment::Expression(_)]
));
}

#[test]
fn validates_textual_domain_values_through_standard_traits() {
assert!(EnvironmentKey::from_str("RUNNER_TEMP").is_ok());
assert!(EnvironmentKey::from_str("invalid-name").is_err());
assert!(ExpressionProgram::from_str("github.actor").is_ok());
assert!(ExpressionProgram::from_str("github.").is_err());
}

#[test]
fn rejects_a_different_workflow_dialect() {
let source = WorkflowSource::new(WorkflowDialect::GitLabCi, b"stages: []".to_vec());
let error = GithubActionsCompiler
.compile(&source)
.err()
.unwrap_or_else(|| panic!("dialect mismatch should fail"));

assert!(matches!(
error,
GithubActionsCompileError::Dialect {
expected: WorkflowDialect::GitHubActions,
actual: WorkflowDialect::GitLabCi,
}
));
}
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use std::str::FromStr;

use syncode_workflow::{
DurationValue, EnvironmentKey, StepKind, TemplateSegment, WorkflowCompiler,
};
use syncode_workflow::{WorkflowDialect, WorkflowSource};
use syncode_workflow_github_actions::compiler::{GithubActionsCompileError, GithubActionsCompiler};
use syncode_workflow_github_actions::expression::{EvaluationContext, ExpressionProgram};

fn compile(
source: &str,
) -> Result<syncode_workflow::ExecutionPlan<ExpressionProgram>, GithubActionsCompileError> {
let hir = GithubActionsCompiler.compile(&WorkflowSource::new(
WorkflowDialect::GitHubActions,
source.as_bytes().to_vec(),
))?;
syncode_workflow::plans(hir)?
.into_iter()
.next()
.ok_or_else(|| syncode_workflow::PlanError::NoJobs.into())
}

#[test]
fn lowers_workflow_into_typed_execution_plan() {
let plan = compile(
r#"
name: CI
env:
TARGET: ${{ github.actor }}
defaults:
run:
shell: sh
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: test
if: success()
run: echo "${{ env.TARGET }}"
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(
plan.workflow_name().map(ToString::to_string).as_deref(),
Some("CI")
);
assert_eq!(plan.job().key().as_ref(), "build");
assert_eq!(plan.environment().len(), 1);
assert_eq!(plan.job().steps().len(), 1);
assert!(matches!(plan.job().steps()[0].kind(), StepKind::Shell(_)));
assert!(
plan.job().steps()[0]
.condition()
.evaluate_condition(&EvaluationContext::default())
.unwrap_or_else(|error| panic!("condition: {error}"))
);
}

#[test]
fn rejects_invalid_expression_during_compilation() {
let error = compile(
r#"
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: github.
run: echo invalid
"#,
)
.err()
.unwrap_or_else(|| panic!("invalid expression should fail"));

assert!(matches!(error, GithubActionsCompileError::Expression(_)));
}

#[test]
fn a_matrix_survives_compilation_and_specialises_on_expansion() {
let plan = compile(
r#"
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
rust: [stable]
target:
- os: linux
features: [tls, sqlite]
steps:
- run: echo "${{ matrix.rust }}"
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

let [specialised] =
syncode_workflow::expand(&plan)
.try_into()
.unwrap_or_else(|plans: Vec<_>| {
panic!(
"one value per dimension is one combination, got {}",
plans.len()
)
});

assert_eq!(
specialised.job().matrix().property("rust"),
Some(&syncode_workflow::Value::String("stable".to_owned()))
);
assert!(matches!(
specialised.job().matrix().property("target"),
Some(syncode_workflow::Value::Object(_))
));
}

#[test]
fn a_matrix_with_several_values_compiles_and_is_expanded_by_the_control_plane() {
let plan = compile(
r#"
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
rust: [stable, beta]
steps:
- run: echo "${{ matrix.rust }}"
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(syncode_workflow::expand(&plan).len(), 2);
}

#[test]
fn compiles_outputs_and_timeouts_into_typed_plan_values() {
let plan = compile(
r#"
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
artifact: ${{ steps.package.outputs.name }}
steps:
- id: package
timeout-minutes: ${{ fromJSON('5') }}
run: echo package
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(plan.job().outputs().len(), 1);
assert!(matches!(
plan.job().timeout().value(),
DurationValue::Fixed(_)
));
assert!(matches!(
plan.job().steps()[0]
.timeout()
.map(syncode_workflow::Timeout::value),
Some(DurationValue::MinutesExpression(_))
));
}

#[test]
fn preserves_runner_selection_and_dependencies_in_execution_plan() {
let plan = compile(
r#"
jobs:
build:
needs: prepare
runs-on:
group: hosted
labels: [linux, "${{ inputs.arch }}"]
steps:
- run: echo build
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(
plan.job()
.needs()
.iter()
.map(AsRef::as_ref)
.collect::<Vec<_>>(),
["prepare"]
);
let runner = plan.job().runner();
assert!(matches!(
runner.group().and_then(|value| value.as_ref().first()),
Some(TemplateSegment::Literal(value)) if value == "hosted"
));
assert_eq!(runner.labels().len(), 2);
assert!(matches!(
runner.labels()[0].as_ref().first(),
Some(TemplateSegment::Literal(value)) if value == "linux"
));
assert!(matches!(
runner.labels()[1].as_ref(),
[TemplateSegment::Expression(_)]
));
}

#[test]
fn validates_textual_domain_values_through_standard_traits() {
assert!(EnvironmentKey::from_str("RUNNER_TEMP").is_ok());
assert!(EnvironmentKey::from_str("invalid-name").is_err());
assert!(ExpressionProgram::from_str("github.actor").is_ok());
assert!(ExpressionProgram::from_str("github.").is_err());
}

#[test]
fn rejects_a_different_workflow_dialect() {
let source = WorkflowSource::new(WorkflowDialect::GitLabCi, b"stages: []".to_vec());
let error = GithubActionsCompiler
.compile(&source)
.err()
.unwrap_or_else(|| panic!("dialect mismatch should fail"));

assert!(matches!(
error,
GithubActionsCompileError::Dialect {
expected: WorkflowDialect::GitHubActions,
actual: WorkflowDialect::GitLabCi,
}
));
}
@@ -1,111 +1,133 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow_github_actions::workflow::{StepKind, Workflow, WorkflowModelError, parse};

#[test]
fn reads_job_and_step_execution_fields() {
let node = parse(
r#"
name: CI
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Build
if: github.ref == 'refs/heads/main'
needs: [prepare, lint]
runs-on: ubuntu-latest
timeout-minutes: 15
container:
image: rust:1.95
services:
postgres:
image: postgres:18
steps:
- uses: actions/checkout@v4
- id: test
name: Test
run: cargo test --all
shell: bash
working-directory: crates/core
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
let job = workflow.jobs.first().unwrap_or_else(|| panic!("job"));

assert_eq!(workflow.name.as_deref(), Some("CI"));
assert_eq!(job.id, "build");
assert_eq!(job.needs, ["prepare", "lint"]);
assert_eq!(job.steps.len(), 2);
assert_eq!(
job.steps[1].kind,
StepKind::Run {
script: "cargo test --all".to_owned()
}
);
assert_eq!(job.steps[1].shell.as_deref(), Some("bash"));
assert_eq!(
job.steps[1].working_directory.as_deref(),
Some("crates/core")
);
}

#[test]
fn reads_reusable_workflow_job() {
let node = parse(
r#"
jobs:
deploy:
uses: org/repository/.github/workflows/deploy.yml@v2
with:
environment: production
secrets: inherit
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert_eq!(
workflow.jobs[0].reusable_workflow.as_deref(),
Some("org/repository/.github/workflows/deploy.yml@v2")
);
assert!(workflow.jobs[0].steps.is_empty());
}

#[test]
fn rejects_step_with_run_and_uses() {
let node = parse(
r#"
jobs:
invalid:
runs-on: ubuntu-latest
steps:
- run: echo invalid
uses: actions/checkout@v4
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.err()
.unwrap_or_else(|| panic!("invalid step should fail"));

assert!(matches!(error, WorkflowModelError::InvalidStepKind { .. }));
}

#[test]
fn rejects_regular_job_without_steps() {
let node = parse(
r#"
jobs:
invalid:
runs-on: ubuntu-latest
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.err()
.unwrap_or_else(|| panic!("invalid job should fail"));

assert!(matches!(error, WorkflowModelError::InvalidJobKind { .. }));
}
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow_github_actions::workflow::{StepKind, Workflow, WorkflowModelError, parse};

#[test]
fn reads_job_and_step_execution_fields() {
let node = parse(
r#"
name: CI
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Build
if: github.ref == 'refs/heads/main'
needs: [prepare, lint]
runs-on: ubuntu-latest
timeout-minutes: 15
container:
image: rust:1.95
services:
postgres:
image: postgres:18
steps:
- uses: actions/checkout@v4
- id: test
name: Test
run: cargo test --all
shell: bash
working-directory: crates/core
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));
let job = workflow.jobs.first().unwrap_or_else(|| panic!("job"));

assert_eq!(workflow.name.as_deref(), Some("CI"));
assert_eq!(job.id, "build");
assert_eq!(job.needs, ["prepare", "lint"]);
assert_eq!(job.steps.len(), 2);
assert_eq!(
job.steps[1].kind,
StepKind::Run {
script: "cargo test --all".to_owned()
}
);
assert_eq!(job.steps[1].shell.as_deref(), Some("bash"));
assert_eq!(
job.steps[1].working_directory.as_deref(),
Some("crates/core")
);
}

#[test]
fn reads_reusable_workflow_job() {
let node = parse(
r#"
jobs:
deploy:
uses: org/repository/.github/workflows/deploy.yml@v2
with:
environment: production
secrets: inherit
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert_eq!(
workflow.jobs[0].reusable_workflow.as_deref(),
Some("org/repository/.github/workflows/deploy.yml@v2")
);
assert!(workflow.jobs[0].steps.is_empty());
}

#[test]
fn reads_boolean_run_as_shell_command() {
let node = parse(
r#"
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- run: true
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert_eq!(
workflow.jobs[0].steps[0].kind,
StepKind::Run {
script: "true".to_owned()
}
);
}

#[test]
fn rejects_step_with_run_and_uses() {
let node = parse(
r#"
jobs:
invalid:
runs-on: ubuntu-latest
steps:
- run: echo invalid
uses: actions/checkout@v4
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.err()
.unwrap_or_else(|| panic!("invalid step should fail"));

assert!(matches!(error, WorkflowModelError::InvalidStepKind { .. }));
}

#[test]
fn rejects_regular_job_without_steps() {
let node = parse(
r#"
jobs:
invalid:
runs-on: ubuntu-latest
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.err()
.unwrap_or_else(|| panic!("invalid job should fail"));

assert!(matches!(error, WorkflowModelError::InvalidJobKind { .. }));
}
+5 -3
View File
@@ -1,95 +1,97 @@
use crate::{EnvironmentBinding, ServiceKey, Template};

#[derive(Clone, Debug, PartialEq)]
pub struct ContainerDefinition<E> {
image: Template<E>,
credentials: Option<ContainerCredentials<E>>,
environment: Vec<EnvironmentBinding<E>>,
ports: Vec<Template<E>>,
volumes: Vec<Template<E>>,
options: Option<Template<E>>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ContainerCredentials<E> {
username: Template<E>,
password: Template<E>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ServiceDefinition<E> {
key: ServiceKey,
container: ContainerDefinition<E>,
}

impl<E> ContainerDefinition<E> {
pub fn new(
image: Template<E>,
credentials: Option<ContainerCredentials<E>>,
environment: Vec<EnvironmentBinding<E>>,
ports: Vec<Template<E>>,
volumes: Vec<Template<E>>,
options: Option<Template<E>>,
) -> Self {
Self {
image,
credentials,
environment,
ports,
volumes,
options,
}
}

pub const fn image(&self) -> &Template<E> {
&self.image
}

pub const fn credentials(&self) -> Option<&ContainerCredentials<E>> {
self.credentials.as_ref()
}

pub fn environment(&self) -> &[EnvironmentBinding<E>] {
&self.environment
}

pub fn ports(&self) -> &[Template<E>] {
&self.ports
}

pub fn volumes(&self) -> &[Template<E>] {
&self.volumes
}

pub const fn options(&self) -> Option<&Template<E>> {
self.options.as_ref()
}
}

impl<E> ContainerCredentials<E> {
pub const fn new(username: Template<E>, password: Template<E>) -> Self {
Self { username, password }
}

pub const fn username(&self) -> &Template<E> {
&self.username
}

pub const fn password(&self) -> &Template<E> {
&self.password
}
}

impl<E> ServiceDefinition<E> {
pub const fn new(key: ServiceKey, container: ContainerDefinition<E>) -> Self {
Self { key, container }
}

pub const fn key(&self) -> &ServiceKey {
&self.key
}

pub const fn container(&self) -> &ContainerDefinition<E> {
&self.container
}
}
use serde::{Deserialize, Serialize};

use crate::{EnvironmentBinding, ServiceKey, Template};

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ContainerDefinition<E> {
image: Template<E>,
credentials: Option<ContainerCredentials<E>>,
environment: Vec<EnvironmentBinding<E>>,
ports: Vec<Template<E>>,
volumes: Vec<Template<E>>,
options: Option<Template<E>>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ContainerCredentials<E> {
username: Template<E>,
password: Template<E>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ServiceDefinition<E> {
key: ServiceKey,
container: ContainerDefinition<E>,
}

impl<E> ContainerDefinition<E> {
pub fn new(
image: Template<E>,
credentials: Option<ContainerCredentials<E>>,
environment: Vec<EnvironmentBinding<E>>,
ports: Vec<Template<E>>,
volumes: Vec<Template<E>>,
options: Option<Template<E>>,
) -> Self {
Self {
image,
credentials,
environment,
ports,
volumes,
options,
}
}

pub const fn image(&self) -> &Template<E> {
&self.image
}

pub const fn credentials(&self) -> Option<&ContainerCredentials<E>> {
self.credentials.as_ref()
}

pub fn environment(&self) -> &[EnvironmentBinding<E>] {
&self.environment
}

pub fn ports(&self) -> &[Template<E>] {
&self.ports
}

pub fn volumes(&self) -> &[Template<E>] {
&self.volumes
}

pub const fn options(&self) -> Option<&Template<E>> {
self.options.as_ref()
}
}

impl<E> ContainerCredentials<E> {
pub const fn new(username: Template<E>, password: Template<E>) -> Self {
Self { username, password }
}

pub const fn username(&self) -> &Template<E> {
&self.username
}

pub const fn password(&self) -> &Template<E> {
&self.password
}
}

impl<E> ServiceDefinition<E> {
pub const fn new(key: ServiceKey, container: ContainerDefinition<E>) -> Self {
Self { key, container }
}

pub const fn key(&self) -> &ServiceKey {
&self.key
}

pub const fn container(&self) -> &ContainerDefinition<E> {
&self.container
}
}
+23 -1
View File
@@ -1,78 +1,100 @@
use std::sync::Arc;

use crate::PropertyName;

#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Arc<Vec<Self>>),
Object(Arc<DynamicObject>),
}

#[macro_export]
macro_rules! domain_map {
($name:ident, $key:ty, $value:ty) => {
#[derive(Clone, Debug, Default, PartialEq)]
pub struct $name(::std::collections::BTreeMap<$key, $value>);

impl $name {
#[must_use]
pub fn get(&self, name: &$key) -> Option<&$value> {
self.0.get(name)
}

pub fn insert(&mut self, name: $key, value: $value) -> Option<$value> {
self.0.insert(name, value)
}

pub fn iter(&self) -> impl Iterator<Item = (&$key, &$value)> {
self.0.iter()
}

pub fn values(&self) -> impl Iterator<Item = &$value> {
self.0.values()
}

#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}

impl FromIterator<($key, $value)> for $name {
fn from_iter<T: IntoIterator<Item = ($key, $value)>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}

impl IntoIterator for $name {
type Item = ($key, $value);
type IntoIter = ::std::collections::btree_map::IntoIter<$key, $value>;

fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}

impl<'a> IntoIterator for &'a $name {
type Item = (&'a $key, &'a $value);
type IntoIter = ::std::collections::btree_map::Iter<'a, $key, $value>;

fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
};
}

domain_map!(DynamicObject, PropertyName, Value);

impl DynamicObject {
#[must_use]
pub fn property(&self, name: &str) -> Option<&Value> {
self.0.get(name)
}
}
use std::collections::BTreeMap;
use std::sync::Arc;

use serde::{Deserialize, Serialize};

use crate::PropertyName;

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Value {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Arc<Vec<Self>>),
Object(Arc<DynamicObject>),
}

#[macro_export]
macro_rules! domain_map {
($name:ident, $key:ty, $value:ty) => {
#[derive(Clone, Debug, Default, PartialEq)]
pub struct $name(::std::collections::BTreeMap<$key, $value>);

impl $name {
#[must_use]
pub fn get(&self, name: &$key) -> Option<&$value> {
self.0.get(name)
}

pub fn insert(&mut self, name: $key, value: $value) -> Option<$value> {
self.0.insert(name, value)
}

pub fn iter(&self) -> impl Iterator<Item = (&$key, &$value)> {
self.0.iter()
}

pub fn values(&self) -> impl Iterator<Item = &$value> {
self.0.values()
}

#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}

impl FromIterator<($key, $value)> for $name {
fn from_iter<T: IntoIterator<Item = ($key, $value)>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}

impl IntoIterator for $name {
type Item = ($key, $value);
type IntoIter = ::std::collections::btree_map::IntoIter<$key, $value>;

fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}

impl<'a> IntoIterator for &'a $name {
type Item = (&'a $key, &'a $value);
type IntoIter = ::std::collections::btree_map::Iter<'a, $key, $value>;

fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
};
}

domain_map!(DynamicObject, PropertyName, Value);

impl DynamicObject {
#[must_use]
pub fn property(&self, name: &str) -> Option<&Value> {
self.0.get(name)
}
}

impl Serialize for DynamicObject {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}

impl<'de> Deserialize<'de> for DynamicObject {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
BTreeMap::deserialize(deserializer).map(Self)
}
}
+19 -3
View File
@@ -1,207 +1,223 @@
use std::borrow::Borrow;
use std::borrow::Cow;
use std::fmt;
use std::str::FromStr;

use thiserror::Error;

macro_rules! text_identifier {
($name:ident, $kind:literal, $validation:expr) => {
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct $name(String);

impl FromStr for $name {
type Err = IdentifierError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
validate(value, $kind, $validation)?;
Ok(Self(value.to_owned()))
}
}

impl TryFrom<String> for $name {
type Error = IdentifierError;

fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}

impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}

impl Borrow<str> for $name {
fn borrow(&self) -> &str {
&self.0
}
}

impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
};
}

text_identifier!(WorkflowName, "workflow name", Validation::Text);
text_identifier!(JobKey, "job key", Validation::Identifier);
text_identifier!(ServiceKey, "service key", Validation::Identifier);
text_identifier!(StepReference, "step reference", Validation::Identifier);
text_identifier!(EnvironmentKey, "environment key", Validation::Environment);
text_identifier!(InputName, "input name", Validation::Input);
text_identifier!(SecretName, "secret name", Validation::Identifier);
text_identifier!(VariableName, "variable name", Validation::Identifier);
text_identifier!(OutputName, "output name", Validation::Identifier);

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct PropertyName(Cow<'static, str>);

impl PropertyName {
#[must_use]
pub const fn literal(value: &'static str) -> Self {
assert!(!value.is_empty(), "property name must not be empty");
Self(Cow::Borrowed(value))
}
}

impl FromStr for PropertyName {
type Err = IdentifierError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
validate(value, "property name", Validation::Text)?;
Ok(Self(Cow::Owned(value.to_owned())))
}
}

impl TryFrom<String> for PropertyName {
type Error = IdentifierError;

fn try_from(value: String) -> Result<Self, Self::Error> {
validate(&value, "property name", Validation::Text)?;
Ok(Self(Cow::Owned(value)))
}
}

impl AsRef<str> for PropertyName {
fn as_ref(&self) -> &str {
&self.0
}
}

impl Borrow<str> for PropertyName {
fn borrow(&self) -> &str {
&self.0
}
}

impl fmt::Display for PropertyName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}

macro_rules! property_from_identifier {
($name:ty) => {
impl From<&$name> for PropertyName {
fn from(value: &$name) -> Self {
Self(Cow::Owned(value.as_ref().to_owned()))
}
}
};
}

property_from_identifier!(JobKey);
property_from_identifier!(OutputName);
property_from_identifier!(SecretName);
property_from_identifier!(VariableName);

#[derive(Clone, Copy)]
enum Validation {
Text,
Identifier,
Environment,
Input,
}

#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct StepOrdinal(u32);

impl StepOrdinal {
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value)
}
}

#[derive(Debug, Error)]
pub enum IdentifierError {
#[error("{0} must not be empty")]
Empty(&'static str),

#[error("{kind} {value:?} has invalid syntax")]
Invalid { kind: &'static str, value: String },

#[error("step ordinal {0} exceeds the supported range")]
StepOrdinal(usize),
}

fn validate(
value: &str,
kind: &'static str,
validation: Validation,
) -> Result<(), IdentifierError> {
if value.is_empty() {
return Err(IdentifierError::Empty(kind));
}
let valid = match validation {
Validation::Text => !value.contains('\0'),
Validation::Identifier => identifier(value, true),
Validation::Environment => identifier(value, false),
Validation::Input => input(value),
};
valid.then_some(()).ok_or_else(|| IdentifierError::Invalid {
kind,
value: value.to_owned(),
})
}

fn identifier(value: &str, hyphen: bool) -> bool {
let mut characters = value.chars();
characters
.next()
.is_some_and(|value| value.is_ascii_alphabetic() || value == '_')
&& characters
.all(|value| value.is_ascii_alphanumeric() || value == '_' || (hyphen && value == '-'))
}

fn input(value: &str) -> bool {
value
.chars()
.all(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '-' | ' '))
}

impl TryFrom<usize> for StepOrdinal {
type Error = IdentifierError;

fn try_from(value: usize) -> Result<Self, Self::Error> {
u32::try_from(value)
.map(Self)
.map_err(|_| IdentifierError::StepOrdinal(value))
}
}

impl From<StepOrdinal> for usize {
fn from(value: StepOrdinal) -> Self {
value.0 as Self
}
}

impl From<StepOrdinal> for u32 {
fn from(value: StepOrdinal) -> Self {
value.0
}
}
use std::borrow::Borrow;
use std::borrow::Cow;
use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};
use thiserror::Error;

macro_rules! text_identifier {
($name:ident, $kind:literal, $validation:expr) => {
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(into = "String", try_from = "String")]
pub struct $name(String);

impl From<$name> for String {
fn from(value: $name) -> Self {
value.0
}
}

impl FromStr for $name {
type Err = IdentifierError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
validate(value, $kind, $validation)?;
Ok(Self(value.to_owned()))
}
}

impl TryFrom<String> for $name {
type Error = IdentifierError;

fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}

impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}

impl Borrow<str> for $name {
fn borrow(&self) -> &str {
&self.0
}
}

impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
};
}

text_identifier!(WorkflowName, "workflow name", Validation::Text);
text_identifier!(JobKey, "job key", Validation::Identifier);
text_identifier!(ServiceKey, "service key", Validation::Identifier);
text_identifier!(StepReference, "step reference", Validation::Identifier);
text_identifier!(EnvironmentKey, "environment key", Validation::Environment);
text_identifier!(InputName, "input name", Validation::Input);
text_identifier!(SecretName, "secret name", Validation::Identifier);
text_identifier!(VariableName, "variable name", Validation::Identifier);
text_identifier!(OutputName, "output name", Validation::Identifier);

#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(into = "String", try_from = "String")]
pub struct PropertyName(Cow<'static, str>);

impl From<PropertyName> for String {
fn from(value: PropertyName) -> Self {
value.0.into_owned()
}
}

impl PropertyName {
#[must_use]
pub const fn literal(value: &'static str) -> Self {
assert!(!value.is_empty(), "property name must not be empty");
Self(Cow::Borrowed(value))
}
}

impl FromStr for PropertyName {
type Err = IdentifierError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
validate(value, "property name", Validation::Text)?;
Ok(Self(Cow::Owned(value.to_owned())))
}
}

impl TryFrom<String> for PropertyName {
type Error = IdentifierError;

fn try_from(value: String) -> Result<Self, Self::Error> {
validate(&value, "property name", Validation::Text)?;
Ok(Self(Cow::Owned(value)))
}
}

impl AsRef<str> for PropertyName {
fn as_ref(&self) -> &str {
&self.0
}
}

impl Borrow<str> for PropertyName {
fn borrow(&self) -> &str {
&self.0
}
}

impl fmt::Display for PropertyName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}

macro_rules! property_from_identifier {
($name:ty) => {
impl From<&$name> for PropertyName {
fn from(value: &$name) -> Self {
Self(Cow::Owned(value.as_ref().to_owned()))
}
}
};
}

property_from_identifier!(JobKey);
property_from_identifier!(OutputName);
property_from_identifier!(SecretName);
property_from_identifier!(VariableName);

#[derive(Clone, Copy)]
enum Validation {
Text,
Identifier,
Environment,
Input,
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct StepOrdinal(u32);

impl StepOrdinal {
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value)
}
}

#[derive(Debug, Error)]
pub enum IdentifierError {
#[error("{0} must not be empty")]
Empty(&'static str),

#[error("{kind} {value:?} has invalid syntax")]
Invalid { kind: &'static str, value: String },

#[error("step ordinal {0} exceeds the supported range")]
StepOrdinal(usize),
}

fn validate(
value: &str,
kind: &'static str,
validation: Validation,
) -> Result<(), IdentifierError> {
if value.is_empty() {
return Err(IdentifierError::Empty(kind));
}
let valid = match validation {
Validation::Text => !value.contains('\0'),
Validation::Identifier => identifier(value, true),
Validation::Environment => identifier(value, false),
Validation::Input => input(value),
};
valid.then_some(()).ok_or_else(|| IdentifierError::Invalid {
kind,
value: value.to_owned(),
})
}

fn identifier(value: &str, hyphen: bool) -> bool {
let mut characters = value.chars();
characters
.next()
.is_some_and(|value| value.is_ascii_alphabetic() || value == '_')
&& characters
.all(|value| value.is_ascii_alphanumeric() || value == '_' || (hyphen && value == '-'))
}

fn input(value: &str) -> bool {
value
.chars()
.all(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '-' | ' '))
}

impl TryFrom<usize> for StepOrdinal {
type Error = IdentifierError;

fn try_from(value: usize) -> Result<Self, Self::Error> {
u32::try_from(value)
.map(Self)
.map_err(|_| IdentifierError::StepOrdinal(value))
}
}

impl From<StepOrdinal> for usize {
fn from(value: StepOrdinal) -> Self {
value.0 as Self
}
}

impl From<StepOrdinal> for u32 {
fn from(value: StepOrdinal) -> Self {
value.0
}
}
+11 -3
View File
@@ -1,33 +1,41 @@
mod compiler;
mod container;
mod dynamic;
mod hir;
mod identifier;
mod plan;
mod source;
mod strategy;
mod template;
mod value;

pub use compiler::WorkflowCompiler;
pub use container::{ContainerCredentials, ContainerDefinition, ServiceDefinition};
pub use dynamic::{DynamicObject, Value};
pub use hir::{
ActionInvocation, HirError, JobHir, JobHirParts, ShellOperation, StepHir, StepHirParts,
StepOperation, WorkflowHir,
};
pub use identifier::{
EnvironmentKey, IdentifierError, InputName, JobKey, OutputName, PropertyName, SecretName,
ServiceKey, StepOrdinal, StepReference, VariableName, WorkflowName,
};
pub use plan::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, ShellStep, StepKind, StepPlan,
StepPlanParts,
};
pub use source::{WorkflowDialect, WorkflowSource};
pub use strategy::{JobStrategy, PositiveIntegerValue};
pub use template::{Template, TemplateSegment};
pub use value::{
BooleanValue, Defaults, DurationValue, DurationValueError, EnvironmentBinding, InputBinding,
OutputBinding, PositiveDuration, RunnerSelection, RunnerSelectionError, Timeout,
};
mod compiler;
mod container;
mod dynamic;
mod hir;
mod identifier;
mod plan;
mod repository;
mod reusable;
mod runtime;
mod source;
mod strategy;
mod template;
mod trigger;
mod value;

pub use compiler::WorkflowCompiler;
pub use container::{ContainerCredentials, ContainerDefinition, ServiceDefinition};
pub use dynamic::{DynamicObject, Value};
pub use hir::{
ActionInvocation, HirError, JobHir, JobHirParts, ShellOperation, StepHir, StepHirParts,
StepOperation, WorkflowHir,
};
pub use identifier::{
EnvironmentKey, IdentifierError, InputName, JobKey, OutputName, PropertyName, SecretName,
ServiceKey, StepOrdinal, StepReference, VariableName, WorkflowName,
};
pub use plan::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, PlanSchemaError,
PlanSchemaVersion, ShellStep, StepKind, StepPlan, StepPlanParts, VersionedPlan, expand, plans,
};
pub use repository::{RepositoryUrl, Revision, SourceValueError};
pub use reusable::{ReusableWorkflow, ReusableWorkflowError};
pub use runtime::JavaScriptRuntime;
pub use source::{CompiledPlan, WorkflowDialect, WorkflowSource};
pub use strategy::{JobStrategy, PositiveIntegerValue};
pub use template::{Template, TemplateSegment};
pub use trigger::{Event, EventKind, Filter, Pattern, Trigger, TriggerError, Triggers};
pub use value::{
BooleanValue, Defaults, DurationValue, DurationValueError, EnvironmentBinding, InputBinding,
OutputBinding, PositiveDuration, RunnerSelection, RunnerSelectionError, Timeout,
};
+5
View File
@@ -1,8 +1,13 @@
mod access;
mod lower;
mod model;

pub use model::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, ShellStep, StepKind, StepPlan,
StepPlanParts,
};
mod access;
mod lower;
mod matrix;
mod model;
mod schema;

pub use lower::plans;
pub use matrix::expand;
pub use model::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, ShellStep, StepKind, StepPlan,
StepPlanParts,
};
pub use schema::{PlanSchemaError, PlanSchemaVersion, VersionedPlan};
+22
View File
@@ -1,38 +1,60 @@
use std::fmt;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkflowDialect {
GitHubActions,
GitLabCi,
}

impl fmt::Display for WorkflowDialect {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::GitHubActions => formatter.write_str("github-actions"),
Self::GitLabCi => formatter.write_str("gitlab-ci"),
}
}
}

pub struct WorkflowSource {
dialect: WorkflowDialect,
payload: Vec<u8>,
}

impl WorkflowSource {
#[must_use]
pub fn new(dialect: WorkflowDialect, payload: Vec<u8>) -> Self {
Self { dialect, payload }
}

#[must_use]
pub const fn dialect(&self) -> WorkflowDialect {
self.dialect
}

#[must_use]
pub fn payload(&self) -> &[u8] {
&self.payload
}
}
use std::fmt;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkflowDialect {
GitHubActions,
GitLabCi,
}

impl fmt::Display for WorkflowDialect {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::GitHubActions => formatter.write_str("github-actions"),
Self::GitLabCi => formatter.write_str("gitlab-ci"),
}
}
}

pub struct WorkflowSource {
dialect: WorkflowDialect,
payload: Vec<u8>,
}

impl WorkflowSource {
#[must_use]
pub fn new(dialect: WorkflowDialect, payload: Vec<u8>) -> Self {
Self { dialect, payload }
}

#[must_use]
pub const fn dialect(&self) -> WorkflowDialect {
self.dialect
}

#[must_use]
pub fn payload(&self) -> &[u8] {
&self.payload
}
}

pub struct CompiledPlan {
dialect: WorkflowDialect,
payload: Vec<u8>,
}

impl CompiledPlan {
#[must_use]
pub fn new(dialect: WorkflowDialect, payload: Vec<u8>) -> Self {
Self { dialect, payload }
}

#[must_use]
pub const fn dialect(&self) -> WorkflowDialect {
self.dialect
}

#[must_use]
pub fn payload(&self) -> &[u8] {
&self.payload
}
}
+10 -2
View File
@@ -1,40 +1,48 @@
use crate::{BooleanValue, DynamicObject};

#[derive(Clone, Debug, PartialEq)]
pub struct JobStrategy<E> {
fail_fast: BooleanValue<E>,
max_parallel: Option<PositiveIntegerValue<E>>,
matrix: DynamicObject,
}

#[derive(Clone, Debug, PartialEq)]
pub enum PositiveIntegerValue<E> {
Literal(u64),
Expression(E),
}

impl<E> JobStrategy<E> {
pub fn new(
fail_fast: BooleanValue<E>,
max_parallel: Option<PositiveIntegerValue<E>>,
matrix: DynamicObject,
) -> Self {
Self {
fail_fast,
max_parallel,
matrix,
}
}

pub const fn fail_fast(&self) -> &BooleanValue<E> {
&self.fail_fast
}

pub const fn max_parallel(&self) -> Option<&PositiveIntegerValue<E>> {
self.max_parallel.as_ref()
}

pub const fn matrix(&self) -> &DynamicObject {
&self.matrix
}
}
use serde::{Deserialize, Serialize};

use crate::{BooleanValue, DynamicObject};

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct JobStrategy<E> {
fail_fast: BooleanValue<E>,
max_parallel: Option<PositiveIntegerValue<E>>,
matrix: DynamicObject,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PositiveIntegerValue<E> {
Literal(u64),
Expression(E),
}

impl<E> JobStrategy<E> {
pub fn new(
fail_fast: BooleanValue<E>,
max_parallel: Option<PositiveIntegerValue<E>>,
matrix: DynamicObject,
) -> Self {
Self {
fail_fast,
max_parallel,
matrix,
}
}

pub const fn fail_fast(&self) -> &BooleanValue<E> {
&self.fail_fast
}

pub const fn max_parallel(&self) -> Option<&PositiveIntegerValue<E>> {
self.max_parallel.as_ref()
}

pub const fn matrix(&self) -> &DynamicObject {
&self.matrix
}

#[must_use]
pub fn with_matrix(self, matrix: DynamicObject) -> Self {
Self { matrix, ..self }
}
}
+6 -2
View File
@@ -1,39 +1,43 @@
#[derive(Clone, Debug, PartialEq)]
pub struct Template<E> {
segments: Vec<TemplateSegment<E>>,
}

#[derive(Clone, Debug, PartialEq)]
pub enum TemplateSegment<E> {
Literal(String),
Expression(E),
}

impl<E> From<String> for Template<E> {
fn from(value: String) -> Self {
Self {
segments: vec![TemplateSegment::Literal(value)],
}
}
}

impl<E> From<Vec<TemplateSegment<E>>> for Template<E> {
fn from(segments: Vec<TemplateSegment<E>>) -> Self {
Self { segments }
}
}

impl<E> AsRef<[TemplateSegment<E>]> for Template<E> {
fn as_ref(&self) -> &[TemplateSegment<E>] {
&self.segments
}
}

impl<E> IntoIterator for Template<E> {
type Item = TemplateSegment<E>;
type IntoIter = std::vec::IntoIter<Self::Item>;

fn into_iter(self) -> Self::IntoIter {
self.segments.into_iter()
}
}
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(transparent)]
pub struct Template<E> {
segments: Vec<TemplateSegment<E>>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TemplateSegment<E> {
Literal(String),
Expression(E),
}

impl<E> From<String> for Template<E> {
fn from(value: String) -> Self {
Self {
segments: vec![TemplateSegment::Literal(value)],
}
}
}

impl<E> From<Vec<TemplateSegment<E>>> for Template<E> {
fn from(segments: Vec<TemplateSegment<E>>) -> Self {
Self { segments }
}
}

impl<E> AsRef<[TemplateSegment<E>]> for Template<E> {
fn as_ref(&self) -> &[TemplateSegment<E>] {
&self.segments
}
}

impl<E> IntoIterator for Template<E> {
type Item = TemplateSegment<E>;
type IntoIter = std::vec::IntoIter<Self::Item>;

fn into_iter(self) -> Self::IntoIter {
self.segments.into_iter()
}
}
+61 -9
View File
@@ -1,180 +1,232 @@
use std::time::Duration;

use thiserror::Error;

use crate::{EnvironmentKey, InputName, OutputName, Template};

#[derive(Clone, Debug, PartialEq)]
pub struct EnvironmentBinding<E> {
name: EnvironmentKey,
value: Template<E>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct InputBinding<E> {
name: InputName,
value: Template<E>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct OutputBinding<E> {
name: OutputName,
value: Template<E>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Defaults<E> {
shell: Option<Template<E>>,
working_directory: Option<Template<E>>,
}

#[derive(Clone, Debug, PartialEq)]
pub enum BooleanValue<E> {
Literal(bool),
Expression(E),
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PositiveDuration(Duration);

#[derive(Clone, Debug, PartialEq)]
pub enum DurationValue<E> {
Fixed(PositiveDuration),
MinutesExpression(E),
}

#[derive(Clone, Debug, PartialEq)]
pub struct Timeout<E> {
value: DurationValue<E>,
maximum: Option<PositiveDuration>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct RunnerSelection<E> {
group: Option<Template<E>>,
labels: Vec<Template<E>>,
}

#[derive(Debug, Error)]
#[error("duration must be a positive whole number of minutes")]
pub struct DurationValueError;

#[derive(Debug, Error)]
#[error("runner selection requires a group or at least one label")]
pub struct RunnerSelectionError;

impl<E> EnvironmentBinding<E> {
pub fn new(name: EnvironmentKey, value: Template<E>) -> Self {
Self { name, value }
}

pub const fn name(&self) -> &EnvironmentKey {
&self.name
}

pub const fn value(&self) -> &Template<E> {
&self.value
}
}

impl<E> InputBinding<E> {
pub fn new(name: InputName, value: Template<E>) -> Self {
Self { name, value }
}

pub const fn name(&self) -> &InputName {
&self.name
}

pub const fn value(&self) -> &Template<E> {
&self.value
}
}

impl<E> OutputBinding<E> {
pub fn new(name: OutputName, value: Template<E>) -> Self {
Self { name, value }
}

pub const fn name(&self) -> &OutputName {
&self.name
}

pub const fn value(&self) -> &Template<E> {
&self.value
}
}

impl<E> Defaults<E> {
pub fn new(shell: Option<Template<E>>, working_directory: Option<Template<E>>) -> Self {
Self {
shell,
working_directory,
}
}

pub fn shell(&self) -> Option<&Template<E>> {
self.shell.as_ref()
}

pub fn working_directory(&self) -> Option<&Template<E>> {
self.working_directory.as_ref()
}
}

impl<E> Default for Defaults<E> {
fn default() -> Self {
Self {
shell: None,
working_directory: None,
}
}
}

impl PositiveDuration {
pub fn from_minutes(minutes: u64) -> Result<Self, DurationValueError> {
let seconds = minutes
.checked_mul(60)
.filter(|_| minutes > 0)
.ok_or(DurationValueError)?;
Ok(Self(Duration::from_secs(seconds)))
}

pub const fn get(self) -> Duration {
self.0
}
}

impl<E> Timeout<E> {
pub const fn new(value: DurationValue<E>, maximum: Option<PositiveDuration>) -> Self {
Self { value, maximum }
}

pub const fn value(&self) -> &DurationValue<E> {
&self.value
}

pub const fn maximum(&self) -> Option<PositiveDuration> {
self.maximum
}
}

impl<E> RunnerSelection<E> {
pub fn try_new(
group: Option<Template<E>>,
labels: Vec<Template<E>>,
) -> Result<Self, RunnerSelectionError> {
if group.is_none() && labels.is_empty() {
return Err(RunnerSelectionError);
}
Ok(Self { group, labels })
}

pub fn group(&self) -> Option<&Template<E>> {
self.group.as_ref()
}

pub fn labels(&self) -> &[Template<E>] {
&self.labels
}
}
use std::time::Duration;

use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{EnvironmentKey, InputName, OutputName, Template};

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct EnvironmentBinding<E> {
name: EnvironmentKey,
value: Template<E>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct InputBinding<E> {
name: InputName,
value: Template<E>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct OutputBinding<E> {
name: OutputName,
value: Template<E>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Defaults<E> {
shell: Option<Template<E>>,
working_directory: Option<Template<E>>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BooleanValue<E> {
Literal(bool),
Expression(E),
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(into = "u64", try_from = "u64")]
pub struct PositiveDuration(Duration);

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DurationValue<E> {
Fixed(PositiveDuration),
MinutesExpression(E),
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Timeout<E> {
value: DurationValue<E>,
maximum: Option<PositiveDuration>,
}

#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(
bound(deserialize = "E: Deserialize<'de>"),
try_from = "RunnerSelectionFields<E>"
)]
pub struct RunnerSelection<E> {
group: Option<Template<E>>,
labels: Vec<Template<E>>,
}

#[derive(Deserialize, Serialize)]
struct RunnerSelectionFields<E> {
group: Option<Template<E>>,
labels: Vec<Template<E>>,
}

#[derive(Debug, Error)]
#[error("duration must be a positive whole number of minutes")]
pub struct DurationValueError;

#[derive(Debug, Error)]
#[error("runner selection requires a group or at least one label")]
pub struct RunnerSelectionError;

impl<E> EnvironmentBinding<E> {
pub fn new(name: EnvironmentKey, value: Template<E>) -> Self {
Self { name, value }
}

pub const fn name(&self) -> &EnvironmentKey {
&self.name
}

pub const fn value(&self) -> &Template<E> {
&self.value
}
}

impl<E> InputBinding<E> {
pub fn new(name: InputName, value: Template<E>) -> Self {
Self { name, value }
}

pub const fn name(&self) -> &InputName {
&self.name
}

pub const fn value(&self) -> &Template<E> {
&self.value
}
}

impl<E> OutputBinding<E> {
pub fn new(name: OutputName, value: Template<E>) -> Self {
Self { name, value }
}

pub const fn name(&self) -> &OutputName {
&self.name
}

pub const fn value(&self) -> &Template<E> {
&self.value
}
}

impl<E> Defaults<E> {
pub fn new(shell: Option<Template<E>>, working_directory: Option<Template<E>>) -> Self {
Self {
shell,
working_directory,
}
}

pub fn shell(&self) -> Option<&Template<E>> {
self.shell.as_ref()
}

pub fn working_directory(&self) -> Option<&Template<E>> {
self.working_directory.as_ref()
}
}

impl<E> Default for Defaults<E> {
fn default() -> Self {
Self {
shell: None,
working_directory: None,
}
}
}

impl PositiveDuration {
pub fn from_minutes(minutes: u64) -> Result<Self, DurationValueError> {
let seconds = minutes
.checked_mul(60)
.filter(|_| minutes > 0)
.ok_or(DurationValueError)?;
Ok(Self(Duration::from_secs(seconds)))
}

pub const fn get(self) -> Duration {
self.0
}
}

impl<E> Timeout<E> {
pub const fn new(value: DurationValue<E>, maximum: Option<PositiveDuration>) -> Self {
Self { value, maximum }
}

pub const fn value(&self) -> &DurationValue<E> {
&self.value
}

pub const fn maximum(&self) -> Option<PositiveDuration> {
self.maximum
}
}

impl<E> RunnerSelection<E> {
pub fn try_new(
group: Option<Template<E>>,
labels: Vec<Template<E>>,
) -> Result<Self, RunnerSelectionError> {
if group.is_none() && labels.is_empty() {
return Err(RunnerSelectionError);
}
Ok(Self { group, labels })
}

pub fn group(&self) -> Option<&Template<E>> {
self.group.as_ref()
}

pub fn labels(&self) -> &[Template<E>] {
&self.labels
}
}

impl From<PositiveDuration> for u64 {
fn from(value: PositiveDuration) -> Self {
value.0.as_secs() / 60
}
}

impl TryFrom<u64> for PositiveDuration {
type Error = DurationValueError;

fn try_from(minutes: u64) -> Result<Self, Self::Error> {
Self::from_minutes(minutes)
}
}

impl<E> Serialize for RunnerSelection<E>
where
E: Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut state = serializer.serialize_struct("RunnerSelection", 2)?;
state.serialize_field("group", &self.group)?;
state.serialize_field("labels", &self.labels)?;
state.end()
}
}

impl<E> TryFrom<RunnerSelectionFields<E>> for RunnerSelection<E> {
type Error = RunnerSelectionError;

fn try_from(value: RunnerSelectionFields<E>) -> Result<Self, Self::Error> {
Self::try_new(value.group, value.labels)
}
}
@@ -1,162 +1,172 @@
use super::GithubActionsCompileError;
use crate::workflow::{Job, Node};

pub fn validate(job: &Job, path: &str) -> Result<(), GithubActionsCompileError> {
permissions(job.permissions.as_ref(), &format!("{path}.permissions"))?;
deployment(job.environment.as_ref(), &format!("{path}.environment"))?;
concurrency(job.concurrency.as_ref(), &format!("{path}.concurrency"))?;
if job.reusable_workflow.is_some() {
return Err(GithubActionsCompileError::UnresolvedOrchestration {
path: path.to_owned(),
feature: "reusable workflow call",
});
}
Ok(())
}

fn permissions(value: Option<&Node>, path: &str) -> Result<(), GithubActionsCompileError> {
let Some(value) = value else {
return Ok(());
};
match value {
Node::String(value) if matches!(value.as_str(), "read-all" | "write-all" | "{}") => Ok(()),
Node::Mapping(entries) => {
for (scope, access) in entries {
if !PERMISSION_SCOPES.contains(&scope.as_str()) {
return Err(unsupported(&format!("{path}.{scope}"), "permission scope"));
}
match access {
Node::String(value) if matches!(value.as_str(), "read" | "write" | "none") => {}
_ => return Err(field(&format!("{path}.{scope}"), "read, write, or none")),
}
}
Ok(())
}
_ => Err(field(path, "read-all, write-all, or a permission mapping")),
}
}

fn deployment(value: Option<&Node>, path: &str) -> Result<(), GithubActionsCompileError> {
let Some(value) = value else {
return Ok(());
};
match value {
Node::String(_) => Ok(()),
Node::Mapping(entries) => {
reject_unknown(
entries,
&["name", "url"],
path,
"deployment environment field",
)?;
require_string(entries, "name", path)?;
optional_string(entries, "url", path)
}
_ => Err(field(path, "a string or mapping")),
}
}

fn concurrency(value: Option<&Node>, path: &str) -> Result<(), GithubActionsCompileError> {
let Some(value) = value else {
return Ok(());
};
match value {
Node::String(_) => Ok(()),
Node::Mapping(entries) => {
reject_unknown(
entries,
&["group", "cancel-in-progress"],
path,
"concurrency field",
)?;
require_string(entries, "group", path)?;
if let Some(value) = find(entries, "cancel-in-progress")
&& !matches!(value, Node::Bool(_) | Node::String(_))
{
return Err(field(
&format!("{path}.cancel-in-progress"),
"a boolean or expression",
));
}
Ok(())
}
_ => Err(field(path, "a string or mapping")),
}
}

fn require_string(
entries: &[(String, Node)],
name: &str,
path: &str,
) -> Result<(), GithubActionsCompileError> {
let value =
find(entries, name).ok_or_else(|| field(&format!("{path}.{name}"), "a required string"))?;
matches!(value, Node::String(_))
.then_some(())
.ok_or_else(|| field(&format!("{path}.{name}"), "a string"))
}

fn optional_string(
entries: &[(String, Node)],
name: &str,
path: &str,
) -> Result<(), GithubActionsCompileError> {
match find(entries, name) {
None | Some(Node::String(_)) => Ok(()),
Some(_) => Err(field(&format!("{path}.{name}"), "a string")),
}
}

fn find<'a>(entries: &'a [(String, Node)], name: &str) -> Option<&'a Node> {
entries
.iter()
.find(|(candidate, _)| candidate == name)
.map(|(_, value)| value)
}

fn reject_unknown(
entries: &[(String, Node)],
allowed: &[&str],
path: &str,
feature: &'static str,
) -> Result<(), GithubActionsCompileError> {
if let Some((name, _)) = entries
.iter()
.find(|(name, _)| !allowed.contains(&name.as_str()))
{
return Err(unsupported(&format!("{path}.{name}"), feature));
}
Ok(())
}

fn field(path: &str, expected: &'static str) -> GithubActionsCompileError {
GithubActionsCompileError::Field {
path: path.to_owned(),
expected,
}
}

fn unsupported(path: &str, feature: &'static str) -> GithubActionsCompileError {
GithubActionsCompileError::Unsupported {
path: path.to_owned(),
feature,
}
}

const PERMISSION_SCOPES: &[&str] = &[
"actions",
"attestations",
"checks",
"contents",
"deployments",
"discussions",
"id-token",
"issues",
"models",
"packages",
"pages",
"pull-requests",
"repository-projects",
"security-events",
"statuses",
];
use super::GithubActionsCompileError;
use crate::workflow::{Job, Node};

pub fn validate(job: &Job, path: &str) -> Result<(), GithubActionsCompileError> {
permissions(job.permissions.as_ref(), &format!("{path}.permissions"))?;
deployment(job.environment.as_ref(), &format!("{path}.environment"))?;
concurrency(job.concurrency.as_ref(), &format!("{path}.concurrency"))?;
if let Some(call) = job.reusable_workflow.as_deref() {
// Saying which kind of call it is turns a refusal into an instruction:
// a local one needs the commit's other files, a remote one needs a
// fetch. Both belong to whoever decides what runs.
let feature = match call.parse::<syncode_workflow::ReusableWorkflow>() {
Ok(syncode_workflow::ReusableWorkflow::Local { .. }) => "local reusable workflow call",
Ok(syncode_workflow::ReusableWorkflow::Remote { .. }) => {
"remote reusable workflow call"
}
Err(_) => "reusable workflow call",
};
return Err(GithubActionsCompileError::UnresolvedOrchestration {
path: path.to_owned(),
feature,
});
}
Ok(())
}

fn permissions(value: Option<&Node>, path: &str) -> Result<(), GithubActionsCompileError> {
let Some(value) = value else {
return Ok(());
};
match value {
Node::String(value) if matches!(value.as_str(), "read-all" | "write-all" | "{}") => Ok(()),
Node::Mapping(entries) => {
for (scope, access) in entries {
if !PERMISSION_SCOPES.contains(&scope.as_str()) {
return Err(unsupported(&format!("{path}.{scope}"), "permission scope"));
}
match access {
Node::String(value) if matches!(value.as_str(), "read" | "write" | "none") => {}
_ => return Err(field(&format!("{path}.{scope}"), "read, write, or none")),
}
}
Ok(())
}
_ => Err(field(path, "read-all, write-all, or a permission mapping")),
}
}

fn deployment(value: Option<&Node>, path: &str) -> Result<(), GithubActionsCompileError> {
let Some(value) = value else {
return Ok(());
};
match value {
Node::String(_) => Ok(()),
Node::Mapping(entries) => {
reject_unknown(
entries,
&["name", "url"],
path,
"deployment environment field",
)?;
require_string(entries, "name", path)?;
optional_string(entries, "url", path)
}
_ => Err(field(path, "a string or mapping")),
}
}

fn concurrency(value: Option<&Node>, path: &str) -> Result<(), GithubActionsCompileError> {
let Some(value) = value else {
return Ok(());
};
match value {
Node::String(_) => Ok(()),
Node::Mapping(entries) => {
reject_unknown(
entries,
&["group", "cancel-in-progress"],
path,
"concurrency field",
)?;
require_string(entries, "group", path)?;
if let Some(value) = find(entries, "cancel-in-progress")
&& !matches!(value, Node::Bool(_) | Node::String(_))
{
return Err(field(
&format!("{path}.cancel-in-progress"),
"a boolean or expression",
));
}
Ok(())
}
_ => Err(field(path, "a string or mapping")),
}
}

fn require_string(
entries: &[(String, Node)],
name: &str,
path: &str,
) -> Result<(), GithubActionsCompileError> {
let value =
find(entries, name).ok_or_else(|| field(&format!("{path}.{name}"), "a required string"))?;
matches!(value, Node::String(_))
.then_some(())
.ok_or_else(|| field(&format!("{path}.{name}"), "a string"))
}

fn optional_string(
entries: &[(String, Node)],
name: &str,
path: &str,
) -> Result<(), GithubActionsCompileError> {
match find(entries, name) {
None | Some(Node::String(_)) => Ok(()),
Some(_) => Err(field(&format!("{path}.{name}"), "a string")),
}
}

fn find<'a>(entries: &'a [(String, Node)], name: &str) -> Option<&'a Node> {
entries
.iter()
.find(|(candidate, _)| candidate == name)
.map(|(_, value)| value)
}

fn reject_unknown(
entries: &[(String, Node)],
allowed: &[&str],
path: &str,
feature: &'static str,
) -> Result<(), GithubActionsCompileError> {
if let Some((name, _)) = entries
.iter()
.find(|(name, _)| !allowed.contains(&name.as_str()))
{
return Err(unsupported(&format!("{path}.{name}"), feature));
}
Ok(())
}

fn field(path: &str, expected: &'static str) -> GithubActionsCompileError {
GithubActionsCompileError::Field {
path: path.to_owned(),
expected,
}
}

fn unsupported(path: &str, feature: &'static str) -> GithubActionsCompileError {
GithubActionsCompileError::Unsupported {
path: path.to_owned(),
feature,
}
}

const PERMISSION_SCOPES: &[&str] = &[
"actions",
"attestations",
"checks",
"contents",
"deployments",
"discussions",
"id-token",
"issues",
"models",
"packages",
"pages",
"pull-requests",
"repository-projects",
"security-events",
"statuses",
];
@@ -1,152 +1,159 @@
use std::sync::Arc;

use syncode_workflow::{BooleanValue, JobStrategy, PositiveIntegerValue};
use syncode_workflow::{DynamicObject, PropertyName, Value};

use super::GithubActionsCompileError;
use crate::expression::{DefaultStatusCheck, ExpressionProgram};
use crate::workflow::Node;

pub fn compile(
strategy: Option<&Node>,
path: &str,
) -> Result<JobStrategy<ExpressionProgram>, GithubActionsCompileError> {
let Some(strategy) = strategy else {
return Ok(JobStrategy::new(
BooleanValue::Literal(true),
None,
DynamicObject::default(),
));
};
let strategy = mapping(strategy, path)?;
reject_unknown(strategy, path)?;
Ok(JobStrategy::new(
fail_fast(find(strategy, "fail-fast"), path)?,
max_parallel(find(strategy, "max-parallel"), path)?,
matrix(find(strategy, "matrix"), path)?,
))
}

fn matrix(matrix: Option<&Node>, path: &str) -> Result<DynamicObject, GithubActionsCompileError> {
let Some(matrix) = matrix else {
return Ok(DynamicObject::default());
};
mapping(matrix, &format!("{path}.matrix"))?
.iter()
.map(|(name, values)| {
let values_path = format!("{path}.matrix.{name}");
let values = values
.as_sequence()
.ok_or_else(|| field(&values_path, "a specialized single-value sequence"))?;
let [value] = values else {
return Err(field(&values_path, "a specialized single-value sequence"));
};
Ok((name.parse::<PropertyName>()?, value_from_node(value)?))
})
.collect()
}

fn fail_fast(
value: Option<&Node>,
path: &str,
) -> Result<BooleanValue<ExpressionProgram>, GithubActionsCompileError> {
match value {
None | Some(Node::Null) => Ok(BooleanValue::Literal(true)),
Some(Node::Bool(value)) => Ok(BooleanValue::Literal(*value)),
Some(Node::String(value)) => ExpressionProgram::condition(value, DefaultStatusCheck::None)
.map(BooleanValue::Expression)
.map_err(Into::into),
Some(_) => Err(field(
&format!("{path}.fail-fast"),
"a boolean or expression",
)),
}
}

fn max_parallel(
value: Option<&Node>,
path: &str,
) -> Result<Option<PositiveIntegerValue<ExpressionProgram>>, GithubActionsCompileError> {
let path = format!("{path}.max-parallel");
match value {
None | Some(Node::Null) => Ok(None),
Some(Node::Integer(value)) => positive(*value, &path)
.map(PositiveIntegerValue::Literal)
.map(Some),
Some(Node::String(value)) => match value.parse::<i64>() {
Ok(value) => positive(value, &path)
.map(PositiveIntegerValue::Literal)
.map(Some),
Err(_) => value
.parse::<ExpressionProgram>()
.map(PositiveIntegerValue::Expression)
.map(Some)
.map_err(Into::into),
},
Some(_) => Err(field(&path, "a positive integer or expression")),
}
}

fn positive(value: i64, path: &str) -> Result<u64, GithubActionsCompileError> {
u64::try_from(value)
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| field(path, "a positive integer"))
}

fn value_from_node(node: &Node) -> Result<Value, GithubActionsCompileError> {
Ok(match node {
Node::Null => Value::Null,
Node::Bool(value) => Value::Bool(*value),
Node::Integer(value) => Value::Number(*value as f64),
Node::Number(value) => Value::Number(*value),
Node::String(value) => Value::String(value.clone()),
Node::Sequence(values) => Value::Array(Arc::new(
values
.iter()
.map(value_from_node)
.collect::<Result<_, _>>()?,
)),
Node::Mapping(values) => Value::Object(Arc::new(
values
.iter()
.map(|(name, value)| Ok((name.parse::<PropertyName>()?, value_from_node(value)?)))
.collect::<Result<DynamicObject, GithubActionsCompileError>>()?,
)),
})
}

fn mapping<'a>(
node: &'a Node,
path: &str,
) -> Result<&'a [(String, Node)], GithubActionsCompileError> {
node.as_mapping().ok_or_else(|| field(path, "a mapping"))
}

fn find<'a>(mapping: &'a [(String, Node)], name: &str) -> Option<&'a Node> {
mapping
.iter()
.find(|(candidate, _)| candidate == name)
.map(|(_, value)| value)
}

fn reject_unknown(mapping: &[(String, Node)], path: &str) -> Result<(), GithubActionsCompileError> {
const ALLOWED: &[&str] = &["fail-fast", "max-parallel", "matrix"];
if let Some((name, _)) = mapping
.iter()
.find(|(name, _)| !ALLOWED.contains(&name.as_str()))
{
return Err(GithubActionsCompileError::Unsupported {
path: format!("{path}.{name}"),
feature: "strategy field",
});
}
Ok(())
}

fn field(path: &str, expected: &'static str) -> GithubActionsCompileError {
GithubActionsCompileError::Field {
path: path.to_owned(),
expected,
}
}
use std::sync::Arc;

use syncode_workflow::{BooleanValue, JobStrategy, PositiveIntegerValue};
use syncode_workflow::{DynamicObject, PropertyName, Value};

use super::GithubActionsCompileError;
use crate::expression::{DefaultStatusCheck, ExpressionProgram};
use crate::workflow::Node;

pub fn compile(
strategy: Option<&Node>,
path: &str,
) -> Result<JobStrategy<ExpressionProgram>, GithubActionsCompileError> {
let Some(strategy) = strategy else {
return Ok(JobStrategy::new(
BooleanValue::Literal(true),
None,
DynamicObject::default(),
));
};
let strategy = mapping(strategy, path)?;
reject_unknown(strategy, path)?;
Ok(JobStrategy::new(
fail_fast(find(strategy, "fail-fast"), path)?,
max_parallel(find(strategy, "max-parallel"), path)?,
matrix(find(strategy, "matrix"), path)?,
))
}

fn matrix(matrix: Option<&Node>, path: &str) -> Result<DynamicObject, GithubActionsCompileError> {
let Some(matrix) = matrix else {
return Ok(DynamicObject::default());
};
mapping(matrix, &format!("{path}.matrix"))?
.iter()
.map(|(name, values)| {
let values_path = format!("{path}.matrix.{name}");
let values = values
.as_sequence()
.ok_or_else(|| field(&values_path, "a sequence of values"))?;
if values.is_empty() {
return Err(field(&values_path, "a sequence of values"));
}
let values = values
.iter()
.map(value_from_node)
.collect::<Result<Vec<_>, _>>()?;
Ok((
name.parse::<PropertyName>()?,
Value::Array(Arc::new(values)),
))
})
.collect()
}

fn fail_fast(
value: Option<&Node>,
path: &str,
) -> Result<BooleanValue<ExpressionProgram>, GithubActionsCompileError> {
match value {
None | Some(Node::Null) => Ok(BooleanValue::Literal(true)),
Some(Node::Bool(value)) => Ok(BooleanValue::Literal(*value)),
Some(Node::String(value)) => ExpressionProgram::condition(value, DefaultStatusCheck::None)
.map(BooleanValue::Expression)
.map_err(Into::into),
Some(_) => Err(field(
&format!("{path}.fail-fast"),
"a boolean or expression",
)),
}
}

fn max_parallel(
value: Option<&Node>,
path: &str,
) -> Result<Option<PositiveIntegerValue<ExpressionProgram>>, GithubActionsCompileError> {
let path = format!("{path}.max-parallel");
match value {
None | Some(Node::Null) => Ok(None),
Some(Node::Integer(value)) => positive(*value, &path)
.map(PositiveIntegerValue::Literal)
.map(Some),
Some(Node::String(value)) => match value.parse::<i64>() {
Ok(value) => positive(value, &path)
.map(PositiveIntegerValue::Literal)
.map(Some),
Err(_) => value
.parse::<ExpressionProgram>()
.map(PositiveIntegerValue::Expression)
.map(Some)
.map_err(Into::into),
},
Some(_) => Err(field(&path, "a positive integer or expression")),
}
}

fn positive(value: i64, path: &str) -> Result<u64, GithubActionsCompileError> {
u64::try_from(value)
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| field(path, "a positive integer"))
}

fn value_from_node(node: &Node) -> Result<Value, GithubActionsCompileError> {
Ok(match node {
Node::Null => Value::Null,
Node::Bool(value) => Value::Bool(*value),
Node::Integer(value) => Value::Number(*value as f64),
Node::Number(value) => Value::Number(*value),
Node::String(value) => Value::String(value.clone()),
Node::Sequence(values) => Value::Array(Arc::new(
values
.iter()
.map(value_from_node)
.collect::<Result<_, _>>()?,
)),
Node::Mapping(values) => Value::Object(Arc::new(
values
.iter()
.map(|(name, value)| Ok((name.parse::<PropertyName>()?, value_from_node(value)?)))
.collect::<Result<DynamicObject, GithubActionsCompileError>>()?,
)),
})
}

fn mapping<'a>(
node: &'a Node,
path: &str,
) -> Result<&'a [(String, Node)], GithubActionsCompileError> {
node.as_mapping().ok_or_else(|| field(path, "a mapping"))
}

fn find<'a>(mapping: &'a [(String, Node)], name: &str) -> Option<&'a Node> {
mapping
.iter()
.find(|(candidate, _)| candidate == name)
.map(|(_, value)| value)
}

fn reject_unknown(mapping: &[(String, Node)], path: &str) -> Result<(), GithubActionsCompileError> {
const ALLOWED: &[&str] = &["fail-fast", "max-parallel", "matrix"];
if let Some((name, _)) = mapping
.iter()
.find(|(name, _)| !ALLOWED.contains(&name.as_str()))
{
return Err(GithubActionsCompileError::Unsupported {
path: format!("{path}.{name}"),
feature: "strategy field",
});
}
Ok(())
}

fn field(path: &str, expected: &'static str) -> GithubActionsCompileError {
GithubActionsCompileError::Field {
path: path.to_owned(),
expected,
}
}
@@ -1,150 +1,178 @@
mod access;
mod ast;
mod builtin;
mod context;
mod effect;
mod effect_eval;
mod error;
mod eval;
mod functions;
mod lexer;
mod names;
mod parser;

use std::fmt;
use std::str::FromStr;

use builtin::BuiltinFunction;
pub use context::{ContextValues, EvaluationContext, EvaluationStatus};
pub use effect::{EffectEvaluationError, ExpressionEffects};
pub use error::ExpressionError;
use eval::Evaluator;
pub use names::{ContextName, VendorContextName};
use parser::Parser;

use syncode_workflow::Value;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DefaultStatusCheck {
None,
Success,
Always,
Cancelled,
Failure,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ExpressionProgram(ast::Expression);

pub struct RenderedValue<'a>(&'a Value);

impl FromStr for ExpressionProgram {
type Err = ExpressionError;

fn from_str(input: &str) -> Result<Self, Self::Err> {
Parser::parse(input).map(Self)
}
}

impl ExpressionProgram {
pub fn condition(input: &str, default: DefaultStatusCheck) -> Result<Self, ExpressionError> {
let mut expression =
if default != DefaultStatusCheck::None && expression_body(input).is_empty() {
ast::Expression::Call {
function: default.function(),
arguments: Vec::new(),
}
} else {
Parser::parse(input)?
};
if default != DefaultStatusCheck::None && !expression.has_status_check() {
expression = ast::Expression::And(
Box::new(ast::Expression::Call {
function: default.function(),
arguments: Vec::new(),
}),
Box::new(expression),
);
}
Ok(Self(expression))
}

pub fn evaluate(&self, context: &EvaluationContext) -> Result<Value, ExpressionError> {
Evaluator::new(context).evaluate(&self.0)
}

pub fn evaluate_condition(&self, context: &EvaluationContext) -> Result<bool, ExpressionError> {
self.evaluate(context).map(|value| eval::is_truthy(&value))
}

pub async fn evaluate_effectful<E>(
&self,
context: &EvaluationContext,
effects: &E,
) -> Result<Value, EffectEvaluationError<E::Error>>
where
E: ExpressionEffects,
{
effect_eval::EffectEvaluator::new(context, effects)
.evaluate(&self.0)
.await
}

pub async fn evaluate_condition_effectful<E>(
&self,
context: &EvaluationContext,
effects: &E,
) -> Result<bool, EffectEvaluationError<E::Error>>
where
E: ExpressionEffects,
{
self.evaluate_effectful(context, effects)
.await
.map(|value| eval::is_truthy(&value))
}
}

impl fmt::Display for RenderedValue<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&eval::to_string(self.0))
}
}

#[must_use]
pub const fn rendered(value: &Value) -> RenderedValue<'_> {
RenderedValue(value)
}

pub fn evaluate(input: &str, context: &EvaluationContext) -> Result<Value, ExpressionError> {
input.parse::<ExpressionProgram>()?.evaluate(context)
}

#[must_use]
pub fn coerce_to_string(value: &Value) -> String {
eval::to_string(value)
}

pub fn evaluate_condition(
input: &str,
context: &EvaluationContext,
default: DefaultStatusCheck,
) -> Result<bool, ExpressionError> {
ExpressionProgram::condition(input, default)?.evaluate_condition(context)
}

fn expression_body(input: &str) -> &str {
let input = input.trim();
let input = input.strip_prefix("${{").unwrap_or(input).trim();
input.strip_suffix("}}").unwrap_or(input).trim()
}

impl DefaultStatusCheck {
const fn function(self) -> BuiltinFunction {
match self {
Self::None | Self::Success => BuiltinFunction::Success,
Self::Always => BuiltinFunction::Always,
Self::Cancelled => BuiltinFunction::Cancelled,
Self::Failure => BuiltinFunction::Failure,
}
}
}
mod access;
mod ast;
mod builtin;
mod context;
mod effect;
mod effect_eval;
mod error;
mod eval;
mod functions;
mod lexer;
mod names;
mod parser;

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

use builtin::BuiltinFunction;
pub use context::{ContextValues, EvaluationContext, EvaluationStatus};
pub use effect::{EffectEvaluationError, ExpressionEffects};
pub use error::ExpressionError;
use eval::Evaluator;
pub use names::{ContextName, VendorContextName};
use parser::Parser;

use syncode_workflow::Value;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DefaultStatusCheck {
None,
Success,
Always,
Cancelled,
Failure,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(into = "String", try_from = "String")]
pub struct ExpressionProgram {
source: String,
expression: ast::Expression,
}

pub struct RenderedValue<'a>(&'a Value);

impl FromStr for ExpressionProgram {
type Err = ExpressionError;

fn from_str(input: &str) -> Result<Self, Self::Err> {
Parser::parse(input).map(|expression| Self {
source: input.to_owned(),
expression,
})
}
}

impl TryFrom<String> for ExpressionProgram {
type Error = ExpressionError;

fn try_from(value: String) -> Result<Self, Self::Error> {
Parser::parse(&value).map(|expression| Self {
source: value,
expression,
})
}
}

impl From<ExpressionProgram> for String {
fn from(value: ExpressionProgram) -> Self {
value.source
}
}

impl fmt::Display for ExpressionProgram {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.source)
}
}

impl ExpressionProgram {
pub fn condition(input: &str, default: DefaultStatusCheck) -> Result<Self, ExpressionError> {
if default == DefaultStatusCheck::None {
return input.parse();
}
let body = expression_body(input);
if body.is_empty() {
return format!("{}()", default.function()).parse();
}
let expression = Parser::parse(input)?;
if expression.has_status_check() {
return Ok(Self {
source: input.to_owned(),
expression,
});
}
format!("{}() && ({body})", default.function()).parse()
}

pub fn evaluate(&self, context: &EvaluationContext) -> Result<Value, ExpressionError> {
Evaluator::new(context).evaluate(&self.expression)
}

pub fn evaluate_condition(&self, context: &EvaluationContext) -> Result<bool, ExpressionError> {
self.evaluate(context).map(|value| eval::is_truthy(&value))
}

pub async fn evaluate_effectful<E>(
&self,
context: &EvaluationContext,
effects: &E,
) -> Result<Value, EffectEvaluationError<E::Error>>
where
E: ExpressionEffects,
{
effect_eval::EffectEvaluator::new(context, effects)
.evaluate(&self.expression)
.await
}

pub async fn evaluate_condition_effectful<E>(
&self,
context: &EvaluationContext,
effects: &E,
) -> Result<bool, EffectEvaluationError<E::Error>>
where
E: ExpressionEffects,
{
self.evaluate_effectful(context, effects)
.await
.map(|value| eval::is_truthy(&value))
}
}

impl fmt::Display for RenderedValue<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&eval::to_string(self.0))
}
}

#[must_use]
pub const fn rendered(value: &Value) -> RenderedValue<'_> {
RenderedValue(value)
}

pub fn evaluate(input: &str, context: &EvaluationContext) -> Result<Value, ExpressionError> {
input.parse::<ExpressionProgram>()?.evaluate(context)
}

#[must_use]
pub fn coerce_to_string(value: &Value) -> String {
eval::to_string(value)
}

pub fn evaluate_condition(
input: &str,
context: &EvaluationContext,
default: DefaultStatusCheck,
) -> Result<bool, ExpressionError> {
ExpressionProgram::condition(input, default)?.evaluate_condition(context)
}

fn expression_body(input: &str) -> &str {
let input = input.trim();
let input = input.strip_prefix("${{").unwrap_or(input).trim();
input.strip_suffix("}}").unwrap_or(input).trim()
}

impl DefaultStatusCheck {
const fn function(self) -> BuiltinFunction {
match self {
Self::None | Self::Success => BuiltinFunction::Success,
Self::Always => BuiltinFunction::Always,
Self::Cancelled => BuiltinFunction::Cancelled,
Self::Failure => BuiltinFunction::Failure,
}
}
}
@@ -1,13 +1,14 @@
mod error;
mod model;
mod model_error;
mod node;
mod parse;
mod read;

pub use error::WorkflowParseError;
pub use model::{Job, Step, StepKind, Workflow};
pub use model_error::WorkflowModelError;
pub use node::Node;
pub use parse::parse;
pub use read::read_step;
mod error;
mod model;
mod model_error;
mod node;
mod parse;
mod read;
mod triggers;

pub use error::WorkflowParseError;
pub use model::{Job, Step, StepKind, Workflow};
pub use model_error::WorkflowModelError;
pub use node::Node;
pub use parse::parse;
pub use read::read_step;
@@ -1,53 +1,54 @@
use super::Node;

#[derive(Clone, Debug, PartialEq)]
pub struct Workflow {
pub name: Option<String>,
pub environment: Option<Node>,
pub defaults: Option<Node>,
pub jobs: Vec<Job>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Job {
pub id: String,
pub name: Option<String>,
pub condition: Option<String>,
pub runs_on: Option<Node>,
pub needs: Vec<String>,
pub permissions: Option<Node>,
pub environment: Option<Node>,
pub concurrency: Option<Node>,
pub outputs: Option<Node>,
pub variables: Option<Node>,
pub defaults: Option<Node>,
pub strategy: Option<Node>,
pub container: Option<Node>,
pub services: Option<Node>,
pub timeout_minutes: Option<Node>,
pub continue_on_error: Option<Node>,
pub reusable_workflow: Option<String>,
pub reusable_inputs: Option<Node>,
pub reusable_secrets: Option<Node>,
pub steps: Vec<Step>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Step {
pub id: Option<String>,
pub name: Option<String>,
pub condition: Option<String>,
pub kind: StepKind,
pub environment: Option<Node>,
pub inputs: Option<Node>,
pub shell: Option<String>,
pub working_directory: Option<String>,
pub continue_on_error: Option<Node>,
pub timeout_minutes: Option<Node>,
}

#[derive(Clone, Debug, PartialEq)]
pub enum StepKind {
Run { script: String },
Uses { reference: String },
}
use super::Node;

#[derive(Clone, Debug, PartialEq)]
pub struct Workflow {
pub name: Option<String>,
pub triggers: syncode_workflow::Triggers,
pub environment: Option<Node>,
pub defaults: Option<Node>,
pub jobs: Vec<Job>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Job {
pub id: String,
pub name: Option<String>,
pub condition: Option<String>,
pub runs_on: Option<Node>,
pub needs: Vec<String>,
pub permissions: Option<Node>,
pub environment: Option<Node>,
pub concurrency: Option<Node>,
pub outputs: Option<Node>,
pub variables: Option<Node>,
pub defaults: Option<Node>,
pub strategy: Option<Node>,
pub container: Option<Node>,
pub services: Option<Node>,
pub timeout_minutes: Option<Node>,
pub continue_on_error: Option<Node>,
pub reusable_workflow: Option<String>,
pub reusable_inputs: Option<Node>,
pub reusable_secrets: Option<Node>,
pub steps: Vec<Step>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Step {
pub id: Option<String>,
pub name: Option<String>,
pub condition: Option<String>,
pub kind: StepKind,
pub environment: Option<Node>,
pub inputs: Option<Node>,
pub shell: Option<String>,
pub working_directory: Option<String>,
pub continue_on_error: Option<Node>,
pub timeout_minutes: Option<Node>,
}

#[derive(Clone, Debug, PartialEq)]
pub enum StepKind {
Run { script: String },
Uses { reference: String },
}
@@ -1,19 +1,22 @@
use thiserror::Error;

#[derive(Debug, Error)]
pub enum WorkflowModelError {
#[error("missing required workflow field {path}")]
Missing { path: String },

#[error("workflow field {path} must be {expected}")]
Expected {
path: String,
expected: &'static str,
},

#[error("workflow step {path} must define exactly one of run or uses")]
InvalidStepKind { path: String },

#[error("workflow job {path} must define either runs-on with steps or uses")]
InvalidJobKind { path: String },
}
use thiserror::Error;

#[derive(Debug, Error)]
pub enum WorkflowModelError {
#[error("missing required workflow field {path}")]
Missing { path: String },

#[error("workflow field {path} must be {expected}")]
Expected {
path: String,
expected: &'static str,
},

#[error("workflow step {path} must define exactly one of run or uses")]
InvalidStepKind { path: String },

#[error("workflow job {path} must define either runs-on with steps or uses")]
InvalidJobKind { path: String },

#[error("workflow field {path} declares a trigger this control plane cannot act on: {reason}")]
UnsupportedTrigger { path: String, reason: String },
}
@@ -1,151 +1,165 @@
use super::{Job, Node, Step, StepKind, Workflow, WorkflowModelError};

impl Workflow {
pub fn from_node(root: &Node) -> Result<Self, WorkflowModelError> {
mapping(root, "$")?;
let jobs = required(root, "jobs", "$")?;
let jobs = mapping(jobs, "$.jobs")?
.iter()
.map(|(id, node)| read_job(id, node))
.collect::<Result<_, _>>()?;
Ok(Self {
name: optional_string(root, "name", "$")?,
environment: root.get("env").cloned(),
defaults: root.get("defaults").cloned(),
jobs,
})
}
}

fn read_job(id: &str, node: &Node) -> Result<Job, WorkflowModelError> {
let path = format!("$.jobs.{id}");
mapping(node, &path)?;
let reusable_workflow = optional_string(node, "uses", &path)?;
let runs_on = node.get("runs-on").cloned();
let steps = match node.get("steps") {
Some(steps) => sequence(steps, &format!("{path}.steps"))?
.iter()
.enumerate()
.map(|(index, step)| read_step(step, &format!("{path}.steps[{index}]")))
.collect::<Result<Vec<_>, _>>()?,
None => Vec::new(),
};
let regular_job = runs_on.is_some() && !steps.is_empty();
let reusable_job = reusable_workflow.is_some() && runs_on.is_none() && steps.is_empty();
if !regular_job && !reusable_job {
return Err(WorkflowModelError::InvalidJobKind { path });
}

Ok(Job {
id: id.to_owned(),
name: optional_string(node, "name", &path)?,
condition: optional_string(node, "if", &path)?,
runs_on,
needs: read_needs(node.get("needs"), &format!("{path}.needs"))?,
permissions: node.get("permissions").cloned(),
environment: node.get("environment").cloned(),
concurrency: node.get("concurrency").cloned(),
outputs: node.get("outputs").cloned(),
variables: node.get("env").cloned(),
defaults: node.get("defaults").cloned(),
strategy: node.get("strategy").cloned(),
container: node.get("container").cloned(),
services: node.get("services").cloned(),
timeout_minutes: node.get("timeout-minutes").cloned(),
continue_on_error: node.get("continue-on-error").cloned(),
reusable_workflow,
reusable_inputs: node.get("with").cloned(),
reusable_secrets: node.get("secrets").cloned(),
steps,
})
}

pub fn read_step(node: &Node, path: &str) -> Result<Step, WorkflowModelError> {
mapping(node, path)?;
let run = optional_string(node, "run", path)?;
let uses = optional_string(node, "uses", path)?;
let kind = match (run, uses) {
(Some(script), None) => StepKind::Run { script },
(None, Some(reference)) => StepKind::Uses { reference },
_ => {
return Err(WorkflowModelError::InvalidStepKind {
path: path.to_owned(),
});
}
};
Ok(Step {
id: optional_string(node, "id", path)?,
name: optional_string(node, "name", path)?,
condition: optional_string(node, "if", path)?,
kind,
environment: node.get("env").cloned(),
inputs: node.get("with").cloned(),
shell: optional_string(node, "shell", path)?,
working_directory: optional_string(node, "working-directory", path)?,
continue_on_error: node.get("continue-on-error").cloned(),
timeout_minutes: node.get("timeout-minutes").cloned(),
})
}

fn read_needs(value: Option<&Node>, path: &str) -> Result<Vec<String>, WorkflowModelError> {
match value {
None => Ok(Vec::new()),
Some(Node::String(value)) => Ok(vec![value.clone()]),
Some(Node::Sequence(values)) => values
.iter()
.map(|value| {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| WorkflowModelError::Expected {
path: path.to_owned(),
expected: "a string or sequence of strings",
})
})
.collect(),
Some(_) => Err(WorkflowModelError::Expected {
path: path.to_owned(),
expected: "a string or sequence of strings",
}),
}
}

fn required<'a>(node: &'a Node, key: &str, parent: &str) -> Result<&'a Node, WorkflowModelError> {
node.get(key).ok_or_else(|| WorkflowModelError::Missing {
path: format!("{parent}.{key}"),
})
}

fn optional_string(
node: &Node,
key: &str,
parent: &str,
) -> Result<Option<String>, WorkflowModelError> {
node.get(key)
.map(|value| {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| WorkflowModelError::Expected {
path: format!("{parent}.{key}"),
expected: "a string",
})
})
.transpose()
}

fn mapping<'a>(node: &'a Node, path: &str) -> Result<&'a [(String, Node)], WorkflowModelError> {
node.as_mapping()
.ok_or_else(|| WorkflowModelError::Expected {
path: path.to_owned(),
expected: "a mapping",
})
}

fn sequence<'a>(node: &'a Node, path: &str) -> Result<&'a [Node], WorkflowModelError> {
node.as_sequence()
.ok_or_else(|| WorkflowModelError::Expected {
path: path.to_owned(),
expected: "a sequence",
})
}
use super::{Job, Node, Step, StepKind, Workflow, WorkflowModelError};

impl Workflow {
pub fn from_node(root: &Node) -> Result<Self, WorkflowModelError> {
mapping(root, "$")?;
let jobs = required(root, "jobs", "$")?;
let jobs = mapping(jobs, "$.jobs")?
.iter()
.map(|(id, node)| read_job(id, node))
.collect::<Result<_, _>>()?;
Ok(Self {
name: optional_string(root, "name", "$")?,
triggers: super::triggers::read(root)?,
environment: root.get("env").cloned(),
defaults: root.get("defaults").cloned(),
jobs,
})
}
}

fn read_job(id: &str, node: &Node) -> Result<Job, WorkflowModelError> {
let path = format!("$.jobs.{id}");
mapping(node, &path)?;
let reusable_workflow = optional_string(node, "uses", &path)?;
let runs_on = node.get("runs-on").cloned();
let steps = match node.get("steps") {
Some(steps) => sequence(steps, &format!("{path}.steps"))?
.iter()
.enumerate()
.map(|(index, step)| read_step(step, &format!("{path}.steps[{index}]")))
.collect::<Result<Vec<_>, _>>()?,
None => Vec::new(),
};
let regular_job = runs_on.is_some() && !steps.is_empty();
let reusable_job = reusable_workflow.is_some() && runs_on.is_none() && steps.is_empty();
if !regular_job && !reusable_job {
return Err(WorkflowModelError::InvalidJobKind { path });
}

Ok(Job {
id: id.to_owned(),
name: optional_string(node, "name", &path)?,
condition: optional_string(node, "if", &path)?,
runs_on,
needs: read_needs(node.get("needs"), &format!("{path}.needs"))?,
permissions: node.get("permissions").cloned(),
environment: node.get("environment").cloned(),
concurrency: node.get("concurrency").cloned(),
outputs: node.get("outputs").cloned(),
variables: node.get("env").cloned(),
defaults: node.get("defaults").cloned(),
strategy: node.get("strategy").cloned(),
container: node.get("container").cloned(),
services: node.get("services").cloned(),
timeout_minutes: node.get("timeout-minutes").cloned(),
continue_on_error: node.get("continue-on-error").cloned(),
reusable_workflow,
reusable_inputs: node.get("with").cloned(),
reusable_secrets: node.get("secrets").cloned(),
steps,
})
}

pub fn read_step(node: &Node, path: &str) -> Result<Step, WorkflowModelError> {
mapping(node, path)?;
let run = optional_run(node, path)?;
let uses = optional_string(node, "uses", path)?;
let kind = match (run, uses) {
(Some(script), None) => StepKind::Run { script },
(None, Some(reference)) => StepKind::Uses { reference },
_ => {
return Err(WorkflowModelError::InvalidStepKind {
path: path.to_owned(),
});
}
};
Ok(Step {
id: optional_string(node, "id", path)?,
name: optional_string(node, "name", path)?,
condition: optional_string(node, "if", path)?,
kind,
environment: node.get("env").cloned(),
inputs: node.get("with").cloned(),
shell: optional_string(node, "shell", path)?,
working_directory: optional_string(node, "working-directory", path)?,
continue_on_error: node.get("continue-on-error").cloned(),
timeout_minutes: node.get("timeout-minutes").cloned(),
})
}

fn read_needs(value: Option<&Node>, path: &str) -> Result<Vec<String>, WorkflowModelError> {
match value {
None => Ok(Vec::new()),
Some(Node::String(value)) => Ok(vec![value.clone()]),
Some(Node::Sequence(values)) => values
.iter()
.map(|value| {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| WorkflowModelError::Expected {
path: path.to_owned(),
expected: "a string or sequence of strings",
})
})
.collect(),
Some(_) => Err(WorkflowModelError::Expected {
path: path.to_owned(),
expected: "a string or sequence of strings",
}),
}
}

fn required<'a>(node: &'a Node, key: &str, parent: &str) -> Result<&'a Node, WorkflowModelError> {
node.get(key).ok_or_else(|| WorkflowModelError::Missing {
path: format!("{parent}.{key}"),
})
}

fn optional_string(
node: &Node,
key: &str,
parent: &str,
) -> Result<Option<String>, WorkflowModelError> {
node.get(key)
.map(|value| {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| WorkflowModelError::Expected {
path: format!("{parent}.{key}"),
expected: "a string",
})
})
.transpose()
}

fn optional_run(node: &Node, parent: &str) -> Result<Option<String>, WorkflowModelError> {
node.get("run")
.map(|value| match value {
Node::String(value) => Ok(value.clone()),
Node::Bool(value) => Ok(value.to_string()),
_ => Err(WorkflowModelError::Expected {
path: format!("{parent}.run"),
expected: "a string or boolean",
}),
})
.transpose()
}

fn mapping<'a>(node: &'a Node, path: &str) -> Result<&'a [(String, Node)], WorkflowModelError> {
node.as_mapping()
.ok_or_else(|| WorkflowModelError::Expected {
path: path.to_owned(),
expected: "a mapping",
})
}

fn sequence<'a>(node: &'a Node, path: &str) -> Result<&'a [Node], WorkflowModelError> {
node.as_sequence()
.ok_or_else(|| WorkflowModelError::Expected {
path: path.to_owned(),
expected: "a sequence",
})
}
@@ -1,14 +1,31 @@
use syncode_workflow::{ExecutionPlan, WorkflowCompiler};
use syncode_workflow::{WorkflowDialect, WorkflowSource};
use syncode_workflow_github_actions::compiler::{GithubActionsCompileError, GithubActionsCompiler};
use syncode_workflow_github_actions::expression::ExpressionProgram;

pub fn compile(
source: &str,
) -> Result<ExecutionPlan<ExpressionProgram>, GithubActionsCompileError> {
let hir = GithubActionsCompiler.compile(&WorkflowSource::new(
WorkflowDialect::GitHubActions,
source.as_bytes().to_vec(),
))?;
hir.try_into().map_err(Into::into)
}
// Every test binary compiles this whole module, so what one of them does not
// reach for is not dead — it is simply another test's fixture.
#![allow(dead_code)]

use syncode_workflow::{ExecutionPlan, PlanError, WorkflowCompiler, plans};
use syncode_workflow::{WorkflowDialect, WorkflowSource};
use syncode_workflow_github_actions::compiler::{GithubActionsCompileError, GithubActionsCompiler};
use syncode_workflow_github_actions::expression::ExpressionProgram;

/// Every plan the workflow describes, in the order its jobs are declared.
pub fn compile_all(
source: &str,
) -> Result<Vec<ExecutionPlan<ExpressionProgram>>, GithubActionsCompileError> {
let hir = GithubActionsCompiler.compile(&WorkflowSource::new(
WorkflowDialect::GitHubActions,
source.as_bytes().to_vec(),
))?;
Ok(plans(hir)?)
}

/// The plan of a workflow that declares one job, which is what most of these
/// fixtures are. A fixture with more than one job has to say which it means, so
/// it asks for all of them instead.
pub fn compile(
source: &str,
) -> Result<ExecutionPlan<ExpressionProgram>, GithubActionsCompileError> {
compile_all(source)?
.into_iter()
.next()
.ok_or_else(|| GithubActionsCompileError::from(PlanError::NoJobs))
}
+13
View File
@@ -1,217 +1,230 @@
use super::model::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, ShellStep, StepKind, StepPlan,
StepPlanParts,
};
use crate::{
BooleanValue, Defaults, EnvironmentBinding, InputBinding, JobKey, OutputBinding,
RunnerSelection, StepOrdinal, StepReference, Template, Timeout, WorkflowName,
};

impl<E> ExecutionPlan<E> {
pub fn new(
workflow_name: Option<WorkflowName>,
environment: Vec<EnvironmentBinding<E>>,
defaults: Defaults<E>,
job: JobPlan<E>,
) -> Self {
Self {
workflow_name,
environment,
defaults,
job,
}
}

pub fn workflow_name(&self) -> Option<&WorkflowName> {
self.workflow_name.as_ref()
}

pub fn environment(&self) -> &[EnvironmentBinding<E>] {
&self.environment
}

pub const fn defaults(&self) -> &Defaults<E> {
&self.defaults
}

pub const fn job(&self) -> &JobPlan<E> {
&self.job
}
}

impl<E> TryFrom<JobPlanParts<E>> for JobPlan<E> {
type Error = PlanError;

fn try_from(parts: JobPlanParts<E>) -> Result<Self, Self::Error> {
if parts.steps.is_empty() {
return Err(PlanError::EmptyJob);
}
Ok(Self {
key: parts.key,
runner: parts.runner,
needs: parts.needs,
name: parts.name,
condition: parts.condition,
environment: parts.environment,
defaults: parts.defaults,
strategy: parts.strategy,
container: parts.container,
services: parts.services,
outputs: parts.outputs,
timeout: parts.timeout,
continue_on_error: parts.continue_on_error,
steps: parts.steps,
})
}
}

impl<E> JobPlan<E> {
pub const fn key(&self) -> &JobKey {
&self.key
}

pub const fn runner(&self) -> &RunnerSelection<E> {
&self.runner
}

pub fn needs(&self) -> &[JobKey] {
&self.needs
}

pub fn name(&self) -> Option<&Template<E>> {
self.name.as_ref()
}

pub const fn condition(&self) -> &E {
&self.condition
}

pub fn environment(&self) -> &[EnvironmentBinding<E>] {
&self.environment
}

pub const fn defaults(&self) -> &Defaults<E> {
&self.defaults
}

pub const fn strategy(&self) -> &crate::JobStrategy<E> {
&self.strategy
}

pub const fn matrix(&self) -> &crate::DynamicObject {
self.strategy.matrix()
}

pub const fn container(&self) -> Option<&crate::ContainerDefinition<E>> {
self.container.as_ref()
}

pub fn services(&self) -> &[crate::ServiceDefinition<E>] {
&self.services
}

pub fn outputs(&self) -> &[OutputBinding<E>] {
&self.outputs
}

pub const fn timeout(&self) -> &Timeout<E> {
&self.timeout
}

pub const fn continue_on_error(&self) -> &BooleanValue<E> {
&self.continue_on_error
}

pub fn steps(&self) -> &[StepPlan<E>] {
&self.steps
}
}

impl<E> From<StepPlanParts<E>> for StepPlan<E> {
fn from(parts: StepPlanParts<E>) -> Self {
Self {
ordinal: parts.ordinal,
reference: parts.reference,
name: parts.name,
condition: parts.condition,
environment: parts.environment,
continue_on_error: parts.continue_on_error,
timeout: parts.timeout,
kind: parts.kind,
}
}
}

impl<E> StepPlan<E> {
pub const fn ordinal(&self) -> StepOrdinal {
self.ordinal
}

pub fn reference(&self) -> Option<&StepReference> {
self.reference.as_ref()
}

pub fn name(&self) -> Option<&Template<E>> {
self.name.as_ref()
}

pub const fn condition(&self) -> &E {
&self.condition
}

pub fn environment(&self) -> &[EnvironmentBinding<E>] {
&self.environment
}

pub const fn continue_on_error(&self) -> &BooleanValue<E> {
&self.continue_on_error
}

pub fn timeout(&self) -> Option<&Timeout<E>> {
self.timeout.as_ref()
}

pub const fn kind(&self) -> &StepKind<E> {
&self.kind
}
}

impl<E> ShellStep<E> {
pub fn new(
script: Template<E>,
shell: Option<Template<E>>,
working_directory: Option<Template<E>>,
) -> Self {
Self {
script,
shell,
working_directory,
}
}

pub const fn script(&self) -> &Template<E> {
&self.script
}

pub fn shell(&self) -> Option<&Template<E>> {
self.shell.as_ref()
}

pub fn working_directory(&self) -> Option<&Template<E>> {
self.working_directory.as_ref()
}
}

impl<E> ActionStep<E> {
pub fn new(reference: Template<E>, inputs: Vec<InputBinding<E>>) -> Self {
Self { reference, inputs }
}

pub const fn reference(&self) -> &Template<E> {
&self.reference
}

pub fn inputs(&self) -> &[InputBinding<E>] {
&self.inputs
}
}
use super::model::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, ShellStep, StepKind, StepPlan,
StepPlanParts,
};
use crate::{
BooleanValue, Defaults, EnvironmentBinding, InputBinding, JobKey, OutputBinding,
RunnerSelection, StepOrdinal, StepReference, Template, Timeout, WorkflowName,
};

impl<E> ExecutionPlan<E> {
pub fn new(
workflow_name: Option<WorkflowName>,
environment: Vec<EnvironmentBinding<E>>,
defaults: Defaults<E>,
job: JobPlan<E>,
) -> Self {
Self {
workflow_name,
environment,
defaults,
job,
}
}

pub fn workflow_name(&self) -> Option<&WorkflowName> {
self.workflow_name.as_ref()
}

pub fn environment(&self) -> &[EnvironmentBinding<E>] {
&self.environment
}

pub const fn defaults(&self) -> &Defaults<E> {
&self.defaults
}

pub const fn job(&self) -> &JobPlan<E> {
&self.job
}
}

impl<E> TryFrom<JobPlanParts<E>> for JobPlan<E> {
type Error = PlanError;

fn try_from(parts: JobPlanParts<E>) -> Result<Self, Self::Error> {
if parts.steps.is_empty() {
return Err(PlanError::EmptyJob);
}
Ok(Self {
key: parts.key,
runner: parts.runner,
needs: parts.needs,
name: parts.name,
condition: parts.condition,
environment: parts.environment,
defaults: parts.defaults,
strategy: parts.strategy,
container: parts.container,
services: parts.services,
outputs: parts.outputs,
timeout: parts.timeout,
continue_on_error: parts.continue_on_error,
steps: parts.steps,
})
}
}

impl<E> ExecutionPlan<E>
where
E: Clone,
{
/// The same plan with the matrix pinned to one combination.
#[must_use]
pub fn with_matrix(&self, matrix: crate::DynamicObject) -> Self {
let mut plan = self.clone();
plan.job.strategy = plan.job.strategy.with_matrix(matrix);
plan
}
}

impl<E> JobPlan<E> {
pub const fn key(&self) -> &JobKey {
&self.key
}

pub const fn runner(&self) -> &RunnerSelection<E> {
&self.runner
}

pub fn needs(&self) -> &[JobKey] {
&self.needs
}

pub fn name(&self) -> Option<&Template<E>> {
self.name.as_ref()
}

pub const fn condition(&self) -> &E {
&self.condition
}

pub fn environment(&self) -> &[EnvironmentBinding<E>] {
&self.environment
}

pub const fn defaults(&self) -> &Defaults<E> {
&self.defaults
}

pub const fn strategy(&self) -> &crate::JobStrategy<E> {
&self.strategy
}

pub const fn matrix(&self) -> &crate::DynamicObject {
self.strategy.matrix()
}

pub const fn container(&self) -> Option<&crate::ContainerDefinition<E>> {
self.container.as_ref()
}

pub fn services(&self) -> &[crate::ServiceDefinition<E>] {
&self.services
}

pub fn outputs(&self) -> &[OutputBinding<E>] {
&self.outputs
}

pub const fn timeout(&self) -> &Timeout<E> {
&self.timeout
}

pub const fn continue_on_error(&self) -> &BooleanValue<E> {
&self.continue_on_error
}

pub fn steps(&self) -> &[StepPlan<E>] {
&self.steps
}
}

impl<E> From<StepPlanParts<E>> for StepPlan<E> {
fn from(parts: StepPlanParts<E>) -> Self {
Self {
ordinal: parts.ordinal,
reference: parts.reference,
name: parts.name,
condition: parts.condition,
environment: parts.environment,
continue_on_error: parts.continue_on_error,
timeout: parts.timeout,
kind: parts.kind,
}
}
}

impl<E> StepPlan<E> {
pub const fn ordinal(&self) -> StepOrdinal {
self.ordinal
}

pub fn reference(&self) -> Option<&StepReference> {
self.reference.as_ref()
}

pub fn name(&self) -> Option<&Template<E>> {
self.name.as_ref()
}

pub const fn condition(&self) -> &E {
&self.condition
}

pub fn environment(&self) -> &[EnvironmentBinding<E>] {
&self.environment
}

pub const fn continue_on_error(&self) -> &BooleanValue<E> {
&self.continue_on_error
}

pub fn timeout(&self) -> Option<&Timeout<E>> {
self.timeout.as_ref()
}

pub const fn kind(&self) -> &StepKind<E> {
&self.kind
}
}

impl<E> ShellStep<E> {
pub fn new(
script: Template<E>,
shell: Option<Template<E>>,
working_directory: Option<Template<E>>,
) -> Self {
Self {
script,
shell,
working_directory,
}
}

pub const fn script(&self) -> &Template<E> {
&self.script
}

pub fn shell(&self) -> Option<&Template<E>> {
self.shell.as_ref()
}

pub fn working_directory(&self) -> Option<&Template<E>> {
self.working_directory.as_ref()
}
}

impl<E> ActionStep<E> {
pub fn new(reference: Template<E>, inputs: Vec<InputBinding<E>>) -> Self {
Self { reference, inputs }
}

pub const fn reference(&self) -> &Template<E> {
&self.reference
}

pub fn inputs(&self) -> &[InputBinding<E>] {
&self.inputs
}
}
+23 -15
View File
@@ -1,77 +1,85 @@
use super::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, ShellStep, StepKind, StepPlan,
StepPlanParts,
};
use crate::{ActionInvocation, JobHir, ShellOperation, StepHir, StepOperation, WorkflowHir};

impl<E> TryFrom<WorkflowHir<E>> for ExecutionPlan<E> {
type Error = PlanError;

fn try_from(hir: WorkflowHir<E>) -> Result<Self, Self::Error> {
let job_count = hir.jobs.len();
if job_count != 1 {
return Err(PlanError::JobCount(job_count));
}
let job = hir.jobs.into_iter().next().ok_or(PlanError::JobCount(0))?;
Ok(Self::new(
hir.name,
hir.environment,
hir.defaults,
lower_job(job)?,
))
}
}

fn lower_job<E>(job: JobHir<E>) -> Result<JobPlan<E>, PlanError> {
JobPlan::try_from(JobPlanParts {
key: job.key,
runner: job.runner,
needs: job.needs,
name: job.name,
condition: job.condition,
environment: job.environment,
defaults: job.defaults,
strategy: job.strategy,
container: job.container,
services: job.services,
outputs: job.outputs,
timeout: job.timeout,
continue_on_error: job.continue_on_error,
steps: job.steps.into_iter().map(lower_step).collect(),
})
}

impl<E> From<StepHir<E>> for StepPlan<E> {
fn from(step: StepHir<E>) -> Self {
lower_step(step)
}
}

fn lower_step<E>(step: StepHir<E>) -> StepPlan<E> {
StepPlanParts {
ordinal: step.ordinal,
reference: step.reference,
name: step.name,
condition: step.condition,
environment: step.environment,
continue_on_error: step.continue_on_error,
timeout: step.timeout,
kind: match step.operation {
StepOperation::Shell(operation) => StepKind::Shell(lower_shell(operation)),
StepOperation::Action(invocation) => StepKind::Action(lower_action(invocation)),
},
}
.into()
}

fn lower_shell<E>(operation: ShellOperation<E>) -> ShellStep<E> {
ShellStep::new(
operation.script,
operation.shell,
operation.working_directory,
)
}

fn lower_action<E>(invocation: ActionInvocation<E>) -> ActionStep<E> {
ActionStep::new(invocation.reference, invocation.inputs)
}
use super::{
ActionStep, ExecutionPlan, JobPlan, JobPlanParts, PlanError, ShellStep, StepKind, StepPlan,
StepPlanParts,
};
use crate::{ActionInvocation, JobHir, ShellOperation, StepHir, StepOperation, WorkflowHir};

/// The plans a workflow describes: one for each job it declares.
///
/// A plan holds a single job because a node executes a single job — an
/// assignment names one, reports one, and holds a lease on one. So a workflow
/// is not one plan but as many as it has jobs, each carrying the `needs` that
/// say what has to finish before it may start.
///
/// What is left to whoever queues them is the order. This says what the jobs
/// are, not when each may run.
pub fn plans<E: Clone>(hir: WorkflowHir<E>) -> Result<Vec<ExecutionPlan<E>>, PlanError> {
if hir.jobs.is_empty() {
return Err(PlanError::NoJobs);
}
hir.jobs
.into_iter()
.map(|job| {
Ok(ExecutionPlan::new(
hir.name.clone(),
hir.environment.clone(),
hir.defaults.clone(),
lower_job(job)?,
))
})
.collect()
}

fn lower_job<E>(job: JobHir<E>) -> Result<JobPlan<E>, PlanError> {
JobPlan::try_from(JobPlanParts {
key: job.key,
runner: job.runner,
needs: job.needs,
name: job.name,
condition: job.condition,
environment: job.environment,
defaults: job.defaults,
strategy: job.strategy,
container: job.container,
services: job.services,
outputs: job.outputs,
timeout: job.timeout,
continue_on_error: job.continue_on_error,
steps: job.steps.into_iter().map(lower_step).collect(),
})
}

impl<E> From<StepHir<E>> for StepPlan<E> {
fn from(step: StepHir<E>) -> Self {
lower_step(step)
}
}

fn lower_step<E>(step: StepHir<E>) -> StepPlan<E> {
StepPlanParts {
ordinal: step.ordinal,
reference: step.reference,
name: step.name,
condition: step.condition,
environment: step.environment,
continue_on_error: step.continue_on_error,
timeout: step.timeout,
kind: match step.operation {
StepOperation::Shell(operation) => StepKind::Shell(lower_shell(operation)),
StepOperation::Action(invocation) => StepKind::Action(lower_action(invocation)),
},
}
.into()
}

fn lower_shell<E>(operation: ShellOperation<E>) -> ShellStep<E> {
ShellStep::new(
operation.script,
operation.shell,
operation.working_directory,
)
}

fn lower_action<E>(invocation: ActionInvocation<E>) -> ActionStep<E> {
ActionStep::new(invocation.reference, invocation.inputs)
}
+10 -8
View File
@@ -1,101 +1,103 @@
use thiserror::Error;

use crate::{
BooleanValue, ContainerDefinition, Defaults, EnvironmentBinding, InputBinding, JobKey,
JobStrategy, OutputBinding, RunnerSelection, ServiceDefinition, StepOrdinal, StepReference,
Template, Timeout, WorkflowName,
};

#[derive(Clone, Debug, PartialEq)]
pub struct ExecutionPlan<E> {
pub(super) workflow_name: Option<WorkflowName>,
pub(super) environment: Vec<EnvironmentBinding<E>>,
pub(super) defaults: Defaults<E>,
pub(super) job: JobPlan<E>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct JobPlan<E> {
pub(super) key: JobKey,
pub(super) runner: RunnerSelection<E>,
pub(super) needs: Vec<JobKey>,
pub(super) name: Option<Template<E>>,
pub(super) condition: E,
pub(super) environment: Vec<EnvironmentBinding<E>>,
pub(super) defaults: Defaults<E>,
pub(super) strategy: JobStrategy<E>,
pub(super) container: Option<ContainerDefinition<E>>,
pub(super) services: Vec<ServiceDefinition<E>>,
pub(super) outputs: Vec<OutputBinding<E>>,
pub(super) timeout: Timeout<E>,
pub(super) continue_on_error: BooleanValue<E>,
pub(super) steps: Vec<StepPlan<E>>,
}

pub struct JobPlanParts<E> {
pub key: JobKey,
pub runner: RunnerSelection<E>,
pub needs: Vec<JobKey>,
pub name: Option<Template<E>>,
pub condition: E,
pub environment: Vec<EnvironmentBinding<E>>,
pub defaults: Defaults<E>,
pub strategy: JobStrategy<E>,
pub container: Option<ContainerDefinition<E>>,
pub services: Vec<ServiceDefinition<E>>,
pub outputs: Vec<OutputBinding<E>>,
pub timeout: Timeout<E>,
pub continue_on_error: BooleanValue<E>,
pub steps: Vec<StepPlan<E>>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct StepPlan<E> {
pub(super) ordinal: StepOrdinal,
pub(super) reference: Option<StepReference>,
pub(super) name: Option<Template<E>>,
pub(super) condition: E,
pub(super) environment: Vec<EnvironmentBinding<E>>,
pub(super) continue_on_error: BooleanValue<E>,
pub(super) timeout: Option<Timeout<E>>,
pub(super) kind: StepKind<E>,
}

pub struct StepPlanParts<E> {
pub ordinal: StepOrdinal,
pub reference: Option<StepReference>,
pub name: Option<Template<E>>,
pub condition: E,
pub environment: Vec<EnvironmentBinding<E>>,
pub continue_on_error: BooleanValue<E>,
pub timeout: Option<Timeout<E>>,
pub kind: StepKind<E>,
}

#[derive(Clone, Debug, PartialEq)]
pub enum StepKind<E> {
Shell(ShellStep<E>),
Action(ActionStep<E>),
}

#[derive(Clone, Debug, PartialEq)]
pub struct ShellStep<E> {
pub(super) script: Template<E>,
pub(super) shell: Option<Template<E>>,
pub(super) working_directory: Option<Template<E>>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ActionStep<E> {
pub(super) reference: Template<E>,
pub(super) inputs: Vec<InputBinding<E>>,
}

#[derive(Debug, Error)]
pub enum PlanError {
#[error("execution plan requires exactly one job, got {0}")]
JobCount(usize),

#[error("job execution plan must contain at least one step")]
EmptyJob,
}
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{
BooleanValue, ContainerDefinition, Defaults, EnvironmentBinding, InputBinding, JobKey,
JobStrategy, OutputBinding, RunnerSelection, ServiceDefinition, StepOrdinal, StepReference,
Template, Timeout, WorkflowName,
};

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ExecutionPlan<E> {
pub(super) workflow_name: Option<WorkflowName>,
pub(super) environment: Vec<EnvironmentBinding<E>>,
pub(super) defaults: Defaults<E>,
pub(super) job: JobPlan<E>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct JobPlan<E> {
pub(super) key: JobKey,
pub(super) runner: RunnerSelection<E>,
pub(super) needs: Vec<JobKey>,
pub(super) name: Option<Template<E>>,
pub(super) condition: E,
pub(super) environment: Vec<EnvironmentBinding<E>>,
pub(super) defaults: Defaults<E>,
pub(super) strategy: JobStrategy<E>,
pub(super) container: Option<ContainerDefinition<E>>,
pub(super) services: Vec<ServiceDefinition<E>>,
pub(super) outputs: Vec<OutputBinding<E>>,
pub(super) timeout: Timeout<E>,
pub(super) continue_on_error: BooleanValue<E>,
pub(super) steps: Vec<StepPlan<E>>,
}

pub struct JobPlanParts<E> {
pub key: JobKey,
pub runner: RunnerSelection<E>,
pub needs: Vec<JobKey>,
pub name: Option<Template<E>>,
pub condition: E,
pub environment: Vec<EnvironmentBinding<E>>,
pub defaults: Defaults<E>,
pub strategy: JobStrategy<E>,
pub container: Option<ContainerDefinition<E>>,
pub services: Vec<ServiceDefinition<E>>,
pub outputs: Vec<OutputBinding<E>>,
pub timeout: Timeout<E>,
pub continue_on_error: BooleanValue<E>,
pub steps: Vec<StepPlan<E>>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct StepPlan<E> {
pub(super) ordinal: StepOrdinal,
pub(super) reference: Option<StepReference>,
pub(super) name: Option<Template<E>>,
pub(super) condition: E,
pub(super) environment: Vec<EnvironmentBinding<E>>,
pub(super) continue_on_error: BooleanValue<E>,
pub(super) timeout: Option<Timeout<E>>,
pub(super) kind: StepKind<E>,
}

pub struct StepPlanParts<E> {
pub ordinal: StepOrdinal,
pub reference: Option<StepReference>,
pub name: Option<Template<E>>,
pub condition: E,
pub environment: Vec<EnvironmentBinding<E>>,
pub continue_on_error: BooleanValue<E>,
pub timeout: Option<Timeout<E>>,
pub kind: StepKind<E>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StepKind<E> {
Shell(ShellStep<E>),
Action(ActionStep<E>),
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ShellStep<E> {
pub(super) script: Template<E>,
pub(super) shell: Option<Template<E>>,
pub(super) working_directory: Option<Template<E>>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ActionStep<E> {
pub(super) reference: Template<E>,
pub(super) inputs: Vec<InputBinding<E>>,
}

#[derive(Debug, Error)]
pub enum PlanError {
#[error("a workflow that declares no jobs describes nothing to run")]
NoJobs,

#[error("job execution plan must contain at least one step")]
EmptyJob,
}
-6
View File
@@ -1,6 +1,0 @@
**/.git
**/target
.idea
.vscode
*.md
LICENSE
-14
View File
@@ -1,14 +1,0 @@
FROM rust:1.95.0-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 AS source

WORKDIR /src
COPY . .

FROM source AS validation
ARG TARGETARCH
RUN --mount=type=cache,id=syncode-workflow-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=syncode-workflow-validation-${TARGETARCH},target=/src/target,sharing=locked \
cargo fmt --check \
&& cargo clippy --workspace --all-targets --all-features -- -D warnings \
&& cargo test --workspace --all-targets --all-features \
&& ./scripts/check-architecture.sh \
&& ./scripts/check-rust-loc.sh
@@ -1,0 +1,154 @@
use syncode_workflow::OutputName;

use super::{
Action, ActionInput, ActionInputName, ActionInputs, ActionOutput, ActionOutputs, ActionRuns,
ActionRuntime, CompositeAction, DockerAction, GoAction, NodeAction,
};
use crate::expression::ExpressionProgram;
use syncode_workflow::{EnvironmentBinding, JavaScriptRuntime, StepPlan, Template};

impl Action {
pub fn name(&self) -> &str {
&self.name
}

pub fn author(&self) -> Option<&str> {
self.author.as_deref()
}

pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}

pub const fn inputs(&self) -> &ActionInputs {
&self.inputs
}

pub const fn outputs(&self) -> &ActionOutputs {
&self.outputs
}

pub const fn runs(&self) -> &ActionRuns {
&self.runs
}
}

impl ActionInputs {
pub fn get(&self, name: &ActionInputName) -> Option<&ActionInput> {
self.0.get(name)
}

pub fn iter(&self) -> impl Iterator<Item = (&ActionInputName, &ActionInput)> {
self.0.iter()
}
}

impl FromIterator<(ActionInputName, ActionInput)> for ActionInputs {
fn from_iter<T: IntoIterator<Item = (ActionInputName, ActionInput)>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}

impl ActionOutputs {
pub fn get(&self, name: &OutputName) -> Option<&ActionOutput> {
self.0.get(name)
}

pub fn iter(&self) -> impl Iterator<Item = (&OutputName, &ActionOutput)> {
self.0.iter()
}
}

impl FromIterator<(OutputName, ActionOutput)> for ActionOutputs {
fn from_iter<T: IntoIterator<Item = (OutputName, ActionOutput)>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}

impl ActionRuns {
pub const fn runtime(&self) -> ActionRuntime {
match self {
Self::Node(action) => ActionRuntime::Node(action.runtime),
Self::Docker(_) => ActionRuntime::Docker,
Self::Composite(_) => ActionRuntime::Composite,
Self::Go(_) => ActionRuntime::Go,
}
}

pub fn environment(&self) -> &[EnvironmentBinding<ExpressionProgram>] {
match self {
Self::Node(action) => &action.environment,
Self::Docker(action) => &action.environment,
Self::Composite(action) => &action.environment,
Self::Go(action) => &action.environment,
}
}
}

impl NodeAction {
pub const fn runtime(&self) -> JavaScriptRuntime {
self.runtime
}

pub fn environment(&self) -> &[EnvironmentBinding<ExpressionProgram>] {
&self.environment
}

pub fn main(&self) -> &str {
&self.main
}

pub fn pre(&self) -> Option<&str> {
self.pre.as_deref()
}

pub fn pre_condition(&self) -> &str {
&self.pre_if
}

pub fn post(&self) -> Option<&str> {
self.post.as_deref()
}

pub fn post_condition(&self) -> &str {
&self.post_if
}
}

impl DockerAction {
pub fn environment(&self) -> &[EnvironmentBinding<ExpressionProgram>] {
&self.environment
}

pub fn image(&self) -> &str {
&self.image
}

pub fn entrypoint(&self) -> Option<&str> {
self.entrypoint.as_deref()
}

pub fn arguments(&self) -> &[Template<ExpressionProgram>] {
&self.args
}
}

impl CompositeAction {
pub fn steps(&self) -> &[StepPlan<ExpressionProgram>] {
&self.steps
}

pub fn environment(&self) -> &[EnvironmentBinding<ExpressionProgram>] {
&self.environment
}
}

impl GoAction {
pub fn environment(&self) -> &[EnvironmentBinding<ExpressionProgram>] {
&self.environment
}

pub fn main(&self) -> &str {
&self.main
}
}
@@ -1,0 +1,45 @@
use thiserror::Error;

use crate::compiler::GithubActionsCompileError;
use crate::workflow::{WorkflowModelError, WorkflowParseError};
use syncode_workflow::IdentifierError;
use syncode_workflow::SourceValueError;

#[derive(Debug, Error)]
pub enum ActionError {
#[error("invalid action reference {0:?}")]
InvalidReference(String),

#[error(transparent)]
SourceValue(#[from] SourceValueError),

#[error(transparent)]
MetadataYaml(#[from] WorkflowParseError),

#[error(transparent)]
MetadataModel(#[from] WorkflowModelError),

#[error(transparent)]
Identifier(#[from] IdentifierError),

#[error(transparent)]
Compile(#[from] GithubActionsCompileError),

#[error("missing required action metadata field {0}")]
MissingField(String),

#[error("action metadata field {path} must be {expected}")]
InvalidField {
path: String,
expected: &'static str,
},

#[error("unsupported action runtime {0:?}")]
UnsupportedRuntime(String),

#[error("action runtime {runtime} requires field {field}")]
MissingRuntimeField {
runtime: super::ActionRuntime,
field: &'static str,
},
}
@@ -1,0 +1,15 @@
mod access;
mod error;
mod model;
mod names;
mod read;
mod reference;

pub use error::ActionError;
pub use model::{
Action, ActionInput, ActionInputs, ActionOutput, ActionOutputs, ActionRuns, ActionRuntime,
CompositeAction, DockerAction, GoAction, NodeAction,
};
pub use names::ActionInputName;
pub use read::parse_action;
pub use reference::{ActionReference, LocalAction, RemoteAction};
@@ -1,0 +1,131 @@
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;

use syncode_workflow::OutputName;
use syncode_workflow::{EnvironmentBinding, JavaScriptRuntime, StepPlan, Template};

use super::ActionInputName;
use crate::expression::ExpressionProgram;

pub struct Action {
pub(crate) name: String,
pub(crate) author: Option<String>,
pub(crate) description: Option<String>,
pub(crate) inputs: ActionInputs,
pub(crate) outputs: ActionOutputs,
pub(crate) runs: ActionRuns,
}

#[derive(Default)]
pub struct ActionInputs(pub(crate) BTreeMap<ActionInputName, ActionInput>);

#[derive(Default)]
pub struct ActionOutputs(pub(crate) BTreeMap<OutputName, ActionOutput>);

pub struct ActionInput {
pub description: Option<String>,
pub required: bool,
pub default: Option<Template<ExpressionProgram>>,
pub deprecation_message: Option<String>,
}

pub struct ActionOutput {
pub description: Option<String>,
pub value: Option<Template<ExpressionProgram>>,
}

pub enum ActionRuns {
Node(NodeAction),
Docker(DockerAction),
Composite(CompositeAction),
Go(GoAction),
}

pub struct NodeAction {
pub(crate) runtime: JavaScriptRuntime,
pub(crate) environment: Vec<EnvironmentBinding<ExpressionProgram>>,
pub(crate) main: String,
pub(crate) pre: Option<String>,
pub(crate) pre_if: String,
pub(crate) post: Option<String>,
pub(crate) post_if: String,
}

pub struct DockerAction {
pub(crate) environment: Vec<EnvironmentBinding<ExpressionProgram>>,
pub(crate) image: String,
pub(crate) entrypoint: Option<String>,
pub(crate) args: Vec<Template<ExpressionProgram>>,
}

pub struct CompositeAction {
pub(crate) environment: Vec<EnvironmentBinding<ExpressionProgram>>,
pub(crate) steps: Vec<StepPlan<ExpressionProgram>>,
}

pub struct GoAction {
pub(crate) environment: Vec<EnvironmentBinding<ExpressionProgram>>,
pub(crate) main: String,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ActionRuntime {
Node(JavaScriptRuntime),
Docker,
Composite,
Go,
}

impl Action {
pub(crate) fn new(
name: String,
author: Option<String>,
description: Option<String>,
inputs: ActionInputs,
outputs: ActionOutputs,
runs: ActionRuns,
) -> Self {
Self {
name,
author,
description,
inputs,
outputs,
runs,
}
}
}

impl From<JavaScriptRuntime> for ActionRuntime {
fn from(value: JavaScriptRuntime) -> Self {
Self::Node(value)
}
}

impl FromStr for ActionRuntime {
type Err = super::ActionError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"node20" => Ok(Self::Node(JavaScriptRuntime::Node20)),
"node24" => Ok(Self::Node(JavaScriptRuntime::Node24)),
"docker" => Ok(Self::Docker),
"composite" => Ok(Self::Composite),
"go" => Ok(Self::Go),
_ => Err(super::ActionError::UnsupportedRuntime(value.to_owned())),
}
}
}

impl fmt::Display for ActionRuntime {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Node(JavaScriptRuntime::Node20) => "node20",
Self::Node(JavaScriptRuntime::Node24) => "node24",
Self::Docker => "docker",
Self::Composite => "composite",
Self::Go => "go",
})
}
}
@@ -1,0 +1,35 @@
use std::fmt;
use std::str::FromStr;

use syncode_workflow::InputName;

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct ActionInputName(InputName);

impl FromStr for ActionInputName {
type Err = syncode_workflow::IdentifierError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
value.to_ascii_lowercase().parse().map(Self)
}
}

impl AsRef<str> for ActionInputName {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}

impl TryFrom<&InputName> for ActionInputName {
type Error = syncode_workflow::IdentifierError;

fn try_from(value: &InputName) -> Result<Self, Self::Error> {
value.as_ref().parse()
}
}

impl fmt::Display for ActionInputName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_ref())
}
}
@@ -1,0 +1,218 @@
use super::{
Action, ActionError, ActionInput, ActionInputName, ActionInputs, ActionOutput, ActionOutputs,
ActionRuns, ActionRuntime, CompositeAction, DockerAction, GoAction, NodeAction,
};
use crate::compiler::values::{environment, template};
use crate::workflow::{Node, parse, read_step};
use syncode_workflow::OutputName;

pub fn parse_action(source: &str) -> Result<Action, ActionError> {
let root = parse(source)?;
mapping(&root, "$")?;
let runs = required(&root, "runs", "$")?;
Ok(Action::new(
required_string(&root, "name", "$")?,
optional_string(&root, "author", "$")?,
optional_string(&root, "description", "$")?,
read_inputs(root.get("inputs"))?,
read_outputs(root.get("outputs"))?,
read_runs(runs)?,
))
}

fn read_runs(node: &Node) -> Result<ActionRuns, ActionError> {
mapping(node, "$.runs")?;
let using = required_string(node, "using", "$.runs")?;
let runtime = using.parse::<ActionRuntime>()?;
let environment = environment(node.get("env"), "$.runs.env")?;
match runtime {
ActionRuntime::Node(runtime) => Ok(ActionRuns::Node(NodeAction {
runtime,
environment,
main: runtime_field(node, runtime.into(), "main")?,
pre: optional_string(node, "pre", "$.runs")?,
pre_if: condition(node, "pre-if")?,
post: optional_string(node, "post", "$.runs")?,
post_if: condition(node, "post-if")?,
})),
ActionRuntime::Docker => Ok(ActionRuns::Docker(DockerAction {
environment,
image: runtime_field(node, runtime, "image")?,
entrypoint: optional_string(node, "entrypoint", "$.runs")?,
args: read_strings(node.get("args"), "$.runs.args")?,
})),
ActionRuntime::Composite => Ok(ActionRuns::Composite(CompositeAction {
environment,
steps: required_steps(node, runtime)?,
})),
ActionRuntime::Go => Ok(ActionRuns::Go(GoAction {
environment,
main: runtime_field(node, runtime, "main")?,
})),
}
}

fn runtime_field(
node: &Node,
runtime: ActionRuntime,
field: &'static str,
) -> Result<String, ActionError> {
optional_string(node, field, "$.runs")?
.ok_or(ActionError::MissingRuntimeField { runtime, field })
}

fn condition(node: &Node, name: &str) -> Result<String, ActionError> {
optional_string(node, name, "$.runs")
.map(|value| value.unwrap_or_else(|| "always()".to_owned()))
}

fn required_steps(
node: &Node,
runtime: ActionRuntime,
) -> Result<Vec<syncode_workflow::StepPlan<crate::expression::ExpressionProgram>>, ActionError> {
let steps = node.get("steps").ok_or(ActionError::MissingRuntimeField {
runtime,
field: "steps",
})?;
let steps = sequence(steps, "$.runs.steps")?
.iter()
.enumerate()
.map(|(index, step)| -> Result<_, ActionError> {
let model = read_step(step, &format!("$.runs.steps[{index}]"))?;
let hir = crate::compiler::lower::step(model, index, "$.runs")
.map_err(ActionError::Compile)?;
Ok(hir.into())
})
.collect::<Result<Vec<_>, _>>()?;
if steps.is_empty() {
return Err(ActionError::MissingRuntimeField {
runtime,
field: "steps",
});
}
Ok(steps)
}

fn read_inputs(value: Option<&Node>) -> Result<ActionInputs, ActionError> {
let Some(value) = value else {
return Ok(ActionInputs::default());
};
mapping(value, "$.inputs")?
.iter()
.map(|(name, node)| {
let path = format!("$.inputs.{name}");
mapping(node, &path)?;
Ok((
name.parse::<ActionInputName>()?,
ActionInput {
description: optional_string(node, "description", &path)?,
required: optional_bool(node, "required", &path)?.unwrap_or(false),
default: optional_scalar(node, "default", &path)?
.as_deref()
.map(template)
.transpose()?,
deprecation_message: optional_string(node, "deprecationMessage", &path)?,
},
))
})
.collect()
}

fn read_outputs(value: Option<&Node>) -> Result<ActionOutputs, ActionError> {
let Some(value) = value else {
return Ok(ActionOutputs::default());
};
mapping(value, "$.outputs")?
.iter()
.map(|(name, node)| {
let path = format!("$.outputs.{name}");
mapping(node, &path)?;
Ok((
name.parse::<OutputName>()?,
ActionOutput {
description: optional_string(node, "description", &path)?,
value: optional_string(node, "value", &path)?
.as_deref()
.map(template)
.transpose()?,
},
))
})
.collect()
}

fn read_strings(
value: Option<&Node>,
path: &str,
) -> Result<Vec<syncode_workflow::Template<crate::expression::ExpressionProgram>>, ActionError> {
value
.map(|value| {
sequence(value, path)?
.iter()
.map(|value| {
scalar(value, path).and_then(|value| template(&value).map_err(Into::into))
})
.collect()
})
.transpose()
.map(Option::unwrap_or_default)
}

fn required<'a>(node: &'a Node, key: &str, parent: &str) -> Result<&'a Node, ActionError> {
node.get(key)
.ok_or_else(|| ActionError::MissingField(format!("{parent}.{key}")))
}

fn required_string(node: &Node, key: &str, parent: &str) -> Result<String, ActionError> {
scalar(required(node, key, parent)?, &format!("{parent}.{key}"))
}

fn optional_string(node: &Node, key: &str, parent: &str) -> Result<Option<String>, ActionError> {
node.get(key)
.map(|value| scalar(value, &format!("{parent}.{key}")))
.transpose()
}

fn optional_scalar(node: &Node, key: &str, parent: &str) -> Result<Option<String>, ActionError> {
optional_string(node, key, parent)
}

fn optional_bool(node: &Node, key: &str, parent: &str) -> Result<Option<bool>, ActionError> {
node.get(key)
.map(|value| match value {
Node::Bool(value) => Ok(*value),
_ => Err(ActionError::InvalidField {
path: format!("{parent}.{key}"),
expected: "a boolean",
}),
})
.transpose()
}

fn scalar(node: &Node, path: &str) -> Result<String, ActionError> {
match node {
Node::Null => Ok(String::new()),
Node::Bool(value) => Ok(value.to_string()),
Node::Integer(value) => Ok(value.to_string()),
Node::Number(value) => Ok(value.to_string()),
Node::String(value) => Ok(value.clone()),
Node::Sequence(_) | Node::Mapping(_) => Err(ActionError::InvalidField {
path: path.to_owned(),
expected: "a scalar",
}),
}
}

fn mapping<'a>(node: &'a Node, path: &str) -> Result<&'a [(String, Node)], ActionError> {
node.as_mapping().ok_or_else(|| ActionError::InvalidField {
path: path.to_owned(),
expected: "a mapping",
})
}

fn sequence<'a>(node: &'a Node, path: &str) -> Result<&'a [Node], ActionError> {
node.as_sequence().ok_or_else(|| ActionError::InvalidField {
path: path.to_owned(),
expected: "a sequence",
})
}
@@ -1,0 +1,133 @@
use std::path::PathBuf;
use std::str::FromStr;

use super::ActionError;
use syncode_workflow::RepositoryUrl;

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ActionReference {
Local(LocalAction),
Docker(String),
Remote(RemoteAction),
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LocalAction(PathBuf);

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteAction {
base_url: Option<RepositoryUrl>,
owner: String,
repository: String,
path: String,
revision: String,
}

impl FromStr for ActionReference {
type Err = ActionError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
if let Some(image) = value.strip_prefix("docker://") {
return if image.is_empty() {
Err(ActionError::InvalidReference(value.to_owned()))
} else {
Ok(Self::Docker(image.to_owned()))
};
}
if value.starts_with("./") {
let path = PathBuf::from(value);
let has_segment = path
.components()
.any(|component| matches!(component, std::path::Component::Normal(_)));
let valid = has_segment
&& path.components().all(|component| {
matches!(
component,
std::path::Component::CurDir | std::path::Component::Normal(_)
)
});
return if valid {
Ok(Self::Local(LocalAction(path)))
} else {
Err(ActionError::InvalidReference(value.to_owned()))
};
}
value.parse::<RemoteAction>().map(Self::Remote)
}
}

impl LocalAction {
pub fn path(&self) -> &std::path::Path {
&self.0
}
}

impl FromStr for RemoteAction {
type Err = ActionError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
let (repository, revision) = value
.rsplit_once('@')
.filter(|(repository, revision)| !repository.is_empty() && !revision.is_empty())
.ok_or_else(|| ActionError::InvalidReference(value.to_owned()))?;
let (base_url, repository) = split_base_url(repository)?;
let segments = repository.split('/').collect::<Vec<_>>();
if segments.len() < 2 || segments.iter().any(|segment| segment.is_empty()) {
return Err(ActionError::InvalidReference(value.to_owned()));
}
Ok(Self {
base_url,
owner: segments[0].to_owned(),
repository: segments[1].to_owned(),
path: segments[2..].join("/"),
revision: revision.to_owned(),
})
}
}

impl RemoteAction {
pub fn repository_url(
&self,
default_base_url: &RepositoryUrl,
) -> Result<RepositoryUrl, ActionError> {
let base = self
.base_url
.as_ref()
.map(AsRef::as_ref)
.unwrap_or_else(|| default_base_url.as_ref());
format!(
"{}/{}/{}",
base.trim_end_matches('/'),
self.owner,
self.repository
)
.parse()
.map_err(Into::into)
}

pub fn base_url(&self) -> Option<&RepositoryUrl> {
self.base_url.as_ref()
}

pub fn path(&self) -> &str {
&self.path
}

pub fn revision(&self) -> &str {
&self.revision
}
}

fn split_base_url(value: &str) -> Result<(Option<RepositoryUrl>, &str), ActionError> {
let Some(scheme) = value.find("://") else {
return Ok((None, value));
};
let path_start = value[scheme + 3..]
.find('/')
.map(|index| scheme + 3 + index)
.ok_or_else(|| ActionError::InvalidReference(value.to_owned()))?;
Ok((
Some(value[..path_start].parse::<RepositoryUrl>()?),
&value[path_start + 1..],
))
}
@@ -1,0 +1,119 @@
use syncode_workflow::{EventKind, Filter, Pattern, Trigger, Triggers};

use super::{Node, WorkflowModelError};

/// Read `on:` in the three shapes the dialect allows: a single event, a
/// sequence of events, or a mapping of events to their filters.
pub fn read(root: &Node) -> Result<Triggers, WorkflowModelError> {
let Some(node) = root.get("on") else {
return Ok(Triggers::default());
};
let triggers =
match node {
Node::String(name) => kind(name)?
.map(|kind| Trigger::new(kind, Filter::default(), Filter::default()))
.into_iter()
.collect(),
Node::Sequence(names) => names
.iter()
.map(|node| {
let name = node.as_str().ok_or_else(|| unsupported("$.on"))?;
Ok(kind(name)?
.map(|kind| Trigger::new(kind, Filter::default(), Filter::default())))
})
.collect::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
Node::Mapping(entries) => entries
.iter()
.map(|(name, node)| read_trigger(name, node))
.collect::<Result<Vec<_>, WorkflowModelError>>()?
.into_iter()
.flatten()
.collect(),
_ => return Err(unsupported("$.on")),
};
Ok(Triggers::new(triggers))
}

fn read_trigger(name: &str, node: &Node) -> Result<Option<Trigger>, WorkflowModelError> {
let Some(kind) = kind(name)? else {
return Ok(None);
};
let path = format!("$.on.{name}");

// A schedule is written as a sequence of cron entries, because it says when
// it fires rather than what it fires for. There is no branch or path to
// filter on, and reading it as a mapping like the others refuses a workflow
// that is written exactly as the dialect says to write one.
if kind == EventKind::Schedule {
node.as_sequence().ok_or_else(|| unsupported(&path))?;
return Ok(Some(Trigger::new(
kind,
Filter::default(),
Filter::default(),
)));
}

let (Node::Mapping(_) | Node::Null) = node else {
return Err(unsupported(&path));
};
Ok(Some(Trigger::new(
kind,
filter(node, "branches", "branches-ignore", &path)?,
filter(node, "paths", "paths-ignore", &path)?,
)))
}

fn filter(
node: &Node,
include: &str,
exclude: &str,
path: &str,
) -> Result<Filter, WorkflowModelError> {
Ok(Filter::new(
patterns(node.get(include), &format!("{path}.{include}"))?,
patterns(node.get(exclude), &format!("{path}.{exclude}"))?,
))
}

fn patterns(node: Option<&Node>, path: &str) -> Result<Vec<Pattern>, WorkflowModelError> {
let Some(node) = node else {
return Ok(Vec::new());
};
node.as_sequence()
.ok_or_else(|| unsupported(path))?
.iter()
.map(|value| {
let value = value.as_str().ok_or_else(|| unsupported(path))?;
Pattern::parse(value).map_err(|error| WorkflowModelError::UnsupportedTrigger {
path: path.to_owned(),
reason: error.to_string(),
})
})
.collect()
}

/// `workflow_call` says the workflow can be called, not that something happened,
/// so it reads as no trigger at all rather than as an unknown event.
fn kind(name: &str) -> Result<Option<EventKind>, WorkflowModelError> {
match name {
"push" => Ok(Some(EventKind::Push)),
"pull_request" => Ok(Some(EventKind::PullRequest)),
"workflow_dispatch" => Ok(Some(EventKind::Manual)),
"schedule" => Ok(Some(EventKind::Schedule)),
"workflow_call" => Ok(None),
other => Err(WorkflowModelError::UnsupportedTrigger {
path: "$.on".to_owned(),
reason: format!("{other:?} is not an event this control plane acts on"),
}),
}
}

fn unsupported(path: &str) -> WorkflowModelError {
WorkflowModelError::UnsupportedTrigger {
path: path.to_owned(),
reason: "the declaration has a shape this compiler does not read".to_owned(),
}
}
@@ -1,0 +1,100 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow::{JavaScriptRuntime, TemplateSegment};
use syncode_workflow_github_actions::action::{
ActionError, ActionInputName, ActionRuns, parse_action,
};

#[test]
fn reads_javascript_action_metadata() {
let action = parse_action(
r#"
name: Build
description: Build the project
inputs:
target:
required: true
default: release
outputs:
artifact:
value: ${{ steps.build.outputs.path }}
runs:
using: node20
main: dist/main.js
pre: dist/pre.js
post: dist/post.js
"#,
)
.unwrap_or_else(|error| panic!("parse action: {error}"));

let ActionRuns::Node(node) = action.runs() else {
panic!("expected node action");
};
let target = "target"
.parse::<ActionInputName>()
.unwrap_or_else(|error| panic!("parse input name: {error}"));
assert_eq!(action.name(), "Build");
assert_eq!(node.runtime(), JavaScriptRuntime::Node20);
assert_eq!(node.main(), "dist/main.js");
assert!(matches!(
action
.inputs()
.get(&target)
.and_then(|input| input.default.as_ref())
.map(AsRef::as_ref),
Some([TemplateSegment::Literal(value)]) if value == "release"
));
assert_eq!(node.pre_condition(), "always()");
assert_eq!(node.post_condition(), "always()");
}

#[test]
fn rejects_runtimes_below_the_supported_floor() {
for using in ["node12", "node16"] {
let error = parse_action(&format!(
"name: Legacy\nruns:\n using: {using}\n main: i.js\n"
))
.err()
.unwrap_or_else(|| panic!("{using} must not be accepted"));

assert!(
matches!(error, ActionError::UnsupportedRuntime(value) if value == using),
"{using}"
);
}
}

#[test]
fn reads_composite_action_steps() {
let action = parse_action(
r#"
name: Composite
runs:
using: composite
steps:
- shell: sh
run: echo works
"#,
)
.unwrap_or_else(|error| panic!("parse action: {error}"));

let ActionRuns::Composite(composite) = action.runs() else {
panic!("expected composite action");
};
assert_eq!(composite.steps().len(), 1);
}

#[test]
fn rejects_runtime_without_required_entrypoint() {
let error = parse_action(
r#"
name: Invalid
runs:
using: node24
"#,
)
.err()
.unwrap_or_else(|| panic!("invalid action should fail"));

assert!(error.to_string().contains("requires field main"));
}
@@ -1,0 +1,60 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow_github_actions::action::{ActionReference, RemoteAction};

#[test]
fn parses_supported_action_references() {
let ActionReference::Local(local) = "./.github/actions/build"
.parse::<ActionReference>()
.unwrap_or_else(|error| panic!("local: {error}"))
else {
panic!("expected local action");
};
assert_eq!(
local.path(),
std::path::Path::new("./.github/actions/build")
);
assert_eq!(
"docker://alpine:3.21"
.parse::<ActionReference>()
.unwrap_or_else(|error| panic!("docker: {error}")),
ActionReference::Docker("alpine:3.21".to_owned())
);
assert_eq!(
"https://gitea.example/team/action/subdir@v2"
.parse::<ActionReference>()
.unwrap_or_else(|error| panic!("remote: {error}")),
ActionReference::Remote(
"https://gitea.example/team/action/subdir@v2"
.parse::<RemoteAction>()
.unwrap_or_else(|error| panic!("remote: {error}"))
)
);
}

#[test]
fn builds_remote_repository_url() {
let ActionReference::Remote(action) = "actions/checkout@v4"
.parse::<ActionReference>()
.unwrap_or_else(|error| panic!("remote: {error}"))
else {
panic!("expected remote action");
};
assert_eq!(
action
.repository_url(
&"https://github.com/"
.parse()
.unwrap_or_else(|error| panic!("repository URL: {error}")),
)
.unwrap_or_else(|error| panic!("url: {error}"))
.to_string(),
"https://github.com/actions/checkout"
);
}

#[test]
fn rejects_remote_action_without_ref() {
assert!("actions/checkout".parse::<ActionReference>().is_err());
assert!("./".parse::<ActionReference>().is_err());
}
@@ -1,0 +1,106 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

#[path = "support/workflow.rs"]
mod workflow;

use syncode_workflow::{Value, expand};

use workflow::compile;

const MATRIX: &str = r#"
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
rust: ["1.95.0", "nightly"]
os: [linux, macos]
steps:
- run: echo "${{ matrix.rust }} on ${{ matrix.os }}"
"#;

fn value(
plan: &syncode_workflow::ExecutionPlan<
syncode_workflow_github_actions::expression::ExpressionProgram,
>,
key: &str,
) -> String {
match plan.job().strategy().matrix().property(key) {
Some(Value::String(value)) => value.clone(),
other => panic!("expected a single string for {key}, got {other:?}"),
}
}

#[test]
fn a_matrix_becomes_one_plan_for_each_combination() {
let plan = compile(MATRIX).unwrap_or_else(|error| panic!("{error}"));

let expanded = expand(&plan);

assert_eq!(expanded.len(), 4, "two rust versions times two systems");
let mut pairs: Vec<(String, String)> = expanded
.iter()
.map(|plan| (value(plan, "rust"), value(plan, "os")))
.collect();
pairs.sort();
assert_eq!(
pairs,
vec![
("1.95.0".to_owned(), "linux".to_owned()),
("1.95.0".to_owned(), "macos".to_owned()),
("nightly".to_owned(), "linux".to_owned()),
("nightly".to_owned(), "macos".to_owned()),
]
);
}

#[test]
fn a_job_without_a_matrix_expands_to_itself() {
let plan = compile(
r#"
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("{error}"));

let expanded = expand(&plan);

assert_eq!(expanded.len(), 1);
assert_eq!(expanded[0], plan);
}

#[test]
fn every_combination_keeps_the_steps_it_came_from() {
let plan = compile(MATRIX).unwrap_or_else(|error| panic!("{error}"));

for expanded in expand(&plan) {
assert_eq!(expanded.job().steps().len(), plan.job().steps().len());
assert_eq!(expanded.job().key(), plan.job().key());
}
}

#[test]
fn an_empty_matrix_dimension_is_refused_at_compilation() {
let error = compile(
r#"
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
rust: []
steps:
- run: echo one
"#,
)
.expect_err("a dimension with no values cannot produce a combination");

assert!(
error.to_string().contains("sequence of values"),
"unexpected error: {error}"
);
}
@@ -1,0 +1,76 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

//! How many plans a workflow describes.
//!
//! A plan holds a single job because a node executes a single job, so a
//! workflow is as many plans as it has jobs. Refusing anything with more than
//! one was the whole of the gap between what this compiler produced and what a
//! forge running the same repository produced.

#[path = "support/workflow.rs"]
mod support;

use support::compile_all;
use syncode_workflow::JobKey;

const PIPELINE: &str = r#"
name: CI
on: [push]
jobs:
resolve:
runs-on: ubuntu-latest
steps:
- run: echo resolve
build:
needs: [resolve]
runs-on: ubuntu-latest
steps:
- run: echo build
publish:
needs: [build, resolve]
runs-on: ubuntu-latest
steps:
- run: echo publish
"#;

#[test]
fn a_workflow_describes_one_plan_for_each_job_it_declares() {
let plans = compile_all(PIPELINE).unwrap_or_else(|error| panic!("compile: {error}"));

let keys: Vec<&str> = plans.iter().map(|plan| plan.job().key().as_ref()).collect();
assert_eq!(keys, ["resolve", "build", "publish"]);
}

#[test]
fn each_plan_keeps_what_its_job_has_to_wait_for() {
let plans = compile_all(PIPELINE).unwrap_or_else(|error| panic!("compile: {error}"));

let needs: Vec<Vec<&str>> = plans
.iter()
.map(|plan| plan.job().needs().iter().map(JobKey::as_ref).collect())
.collect();

assert_eq!(
needs,
[vec![], vec!["resolve"], vec!["build", "resolve"]],
"an order nobody can read is an order nobody can enforce"
);
}

#[test]
fn a_single_job_workflow_is_still_one_plan() {
let plans = compile_all(
r#"
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("compile: {error}"));

assert_eq!(plans.len(), 1);
assert_eq!(plans[0].job().key().as_ref(), "build");
}
@@ -1,0 +1,107 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

#[path = "support/workflow.rs"]
mod workflow;

use syncode_workflow::{PlanSchemaVersion, VersionedPlan};
use syncode_workflow_github_actions::expression::ExpressionProgram;

use workflow::compile;

const WORKFLOW: &str = r#"
name: CI
env:
TARGET: ${{ github.actor }}
jobs:
build:
runs-on: [self-hosted, linux]
timeout-minutes: 30
container:
image: alpine:3.21
strategy:
fail-fast: false
steps:
- id: greet
if: ${{ github.event_name == 'push' }}
env:
GREETING: hello ${{ github.actor }}
run: echo "${GREETING}"
- uses: actions/checkout@v4
with:
fetch-depth: "0"
"#;

fn round_trip(plan: &VersionedPlan<ExpressionProgram>) -> VersionedPlan<ExpressionProgram> {
let encoded = serde_json::to_string(plan).unwrap_or_else(|error| panic!("encode: {error}"));
serde_json::from_str(&encoded).unwrap_or_else(|error| panic!("decode: {error}"))
}

#[test]
fn plan_survives_the_wire_as_itself() {
let plan = VersionedPlan::new(compile(WORKFLOW).unwrap_or_else(|error| panic!("{error}")));

assert_eq!(round_trip(&plan), plan);
}

#[test]
fn plan_carries_the_schema_it_was_compiled_against() {
let plan = VersionedPlan::new(compile(WORKFLOW).unwrap_or_else(|error| panic!("{error}")));

assert_eq!(plan.schema(), PlanSchemaVersion::CURRENT);
assert_eq!(round_trip(&plan).schema(), PlanSchemaVersion::CURRENT);
}

#[test]
fn plan_of_an_unsupported_schema_is_rejected() {
let plan = VersionedPlan::new(compile(WORKFLOW).unwrap_or_else(|error| panic!("{error}")));
let encoded = serde_json::to_string(&plan).unwrap_or_else(|error| panic!("encode: {error}"));
let next = u32::from(PlanSchemaVersion::CURRENT) + 1;
let forged = encoded.replace(
&format!("\"schema\":{}", u32::from(PlanSchemaVersion::CURRENT)),
&format!("\"schema\":{next}"),
);
assert_ne!(
forged, encoded,
"the schema version must appear on the wire"
);

let error = serde_json::from_str::<VersionedPlan<ExpressionProgram>>(&forged)
.expect_err("an unsupported schema version must not decode");

assert!(
error.to_string().contains("is not supported"),
"unexpected error: {error}"
);
}

#[test]
fn expressions_travel_as_source_not_as_a_syntax_tree() {
let plan = VersionedPlan::new(compile(WORKFLOW).unwrap_or_else(|error| panic!("{error}")));

let encoded = serde_json::to_string(&plan).unwrap_or_else(|error| panic!("encode: {error}"));

assert!(
encoded.contains("github.event_name == 'push'"),
"the condition source is missing: {encoded}"
);
assert!(
!encoded.contains("Binary") && !encoded.contains("Call"),
"a syntax tree reached the wire: {encoded}"
);
}

#[test]
fn an_invalid_identifier_does_not_decode_into_a_plan() {
let plan = VersionedPlan::new(compile(WORKFLOW).unwrap_or_else(|error| panic!("{error}")));
let encoded = serde_json::to_string(&plan).unwrap_or_else(|error| panic!("encode: {error}"));
let forged = encoded.replace("\"build\"", "\"1-not-an-identifier\"");
assert_ne!(forged, encoded, "the job key must appear on the wire");

let error = serde_json::from_str::<VersionedPlan<ExpressionProgram>>(&forged)
.expect_err("an invalid job key must not decode");

assert!(
error.to_string().contains("invalid syntax"),
"unexpected error: {error}"
);
}
@@ -1,0 +1,182 @@
//! What a workflow says fires it. `on:` has three shapes in this dialect and
//! each event has its own, so reading one the way another is written refuses a
//! workflow that is spelled exactly as the dialect says to spell it.

#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow_github_actions::workflow::{Workflow, parse};

#[test]
fn reads_the_events_a_workflow_declares() {
let node = parse(
r#"
on:
push:
branches: [main, "release/**"]
paths-ignore: ["**.md"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

let touched = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"release/0.3".to_owned(),
vec!["crates/workflow/src/lib.rs".to_owned()],
);
let docs_only = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"main".to_owned(),
vec!["README.md".to_owned()],
);
let other_branch = syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"wip/x".to_owned(),
vec!["src/main.rs".to_owned()],
);

assert!(workflow.triggers.fire_on(&touched));
assert!(!workflow.triggers.fire_on(&docs_only));
assert!(!workflow.triggers.fire_on(&other_branch));
}

#[test]
fn reads_a_bare_list_of_events() {
let node = parse(
r#"
on: [push, workflow_dispatch]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Manual,
"anything".to_owned(),
Vec::new(),
)));
}

#[test]
fn refuses_an_event_this_control_plane_does_not_act_on() {
let node = parse(
r#"
on: [deployment_status]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.expect_err("an event nobody handles must not read as triggering nothing");

assert!(
error.to_string().contains("cannot act on"),
"unexpected error: {error}"
);
}

#[test]
fn a_callable_workflow_declares_itself_without_declaring_an_event() {
let node = parse(
r#"
on:
workflow_call:
inputs:
target:
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(
workflow.triggers.is_empty(),
"being callable is not an event that fires a run"
);
assert!(!workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"main".to_owned(),
vec!["src/main.rs".to_owned()],
)));
}

#[test]
fn a_schedule_is_read_as_the_sequence_of_cron_entries_it_is_written_as() {
let node = parse(
r#"
on:
schedule:
- cron: "17 3 * * *"
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let workflow = Workflow::from_node(&node).unwrap_or_else(|error| panic!("model: {error}"));

assert!(
!workflow.triggers.is_empty(),
"a schedule alongside a push is a trigger, not a refusal"
);
assert!(workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Schedule,
"main".to_owned(),
Vec::new(),
)));
assert!(
workflow.triggers.fire_on(&syncode_workflow::Event::new(
syncode_workflow::EventKind::Push,
"main".to_owned(),
vec!["src/main.rs".to_owned()],
)),
"reading the schedule must not cost the push its filters"
);
}

#[test]
fn a_schedule_written_as_a_mapping_is_refused_rather_than_guessed_at() {
let node = parse(
r#"
on:
schedule:
cron: "17 3 * * *"
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo one
"#,
)
.unwrap_or_else(|error| panic!("parse: {error}"));
let error = Workflow::from_node(&node)
.expect_err("a schedule that is not a sequence is not a schedule");

assert!(
error.to_string().contains("$.on.schedule"),
"unexpected error: {error}"
);
}
+38
View File
@@ -1,0 +1,38 @@
use crate::{DynamicObject, ExecutionPlan, Value};

/// One plan per combination of the matrix. Expansion belongs to whoever decides
/// what runs, not to whoever executes it, so a node is only ever handed a plan
/// with a single value for each matrix key.
#[must_use]
pub fn expand<E>(plan: &ExecutionPlan<E>) -> Vec<ExecutionPlan<E>>
where
E: Clone,
{
let matrix = plan.job().strategy().matrix();
let combinations = combinations(matrix);
combinations
.into_iter()
.map(|combination| plan.with_matrix(combination))
.collect()
}

fn combinations(matrix: &DynamicObject) -> Vec<DynamicObject> {
let mut combinations = vec![DynamicObject::default()];
for (name, values) in matrix {
let values = match values {
Value::Array(values) => values.as_ref().clone(),
single => vec![single.clone()],
};
combinations = combinations
.into_iter()
.flat_map(|combination| {
values.iter().map(move |value| {
let mut next = combination.clone();
next.insert(name.clone(), value.clone());
next
})
})
.collect();
}
combinations
}
+78
View File
@@ -1,0 +1,78 @@
use std::fmt;

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::ExecutionPlan;

const CURRENT: u32 = 1;

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(into = "u32", try_from = "u32")]
pub struct PlanSchemaVersion(u32);

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct VersionedPlan<E> {
schema: PlanSchemaVersion,
plan: ExecutionPlan<E>,
}

#[derive(Debug, Error)]
pub enum PlanSchemaError {
#[error(
"execution plan schema version {version} is not supported, this build speaks {}",
PlanSchemaVersion::CURRENT
)]
Unsupported { version: u32 },
}

impl PlanSchemaVersion {
pub const CURRENT: Self = Self(CURRENT);
}

impl fmt::Display for PlanSchemaVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.0)
}
}

impl From<PlanSchemaVersion> for u32 {
fn from(value: PlanSchemaVersion) -> Self {
value.0
}
}

impl TryFrom<u32> for PlanSchemaVersion {
type Error = PlanSchemaError;

fn try_from(value: u32) -> Result<Self, Self::Error> {
(value == CURRENT)
.then_some(Self(value))
.ok_or(PlanSchemaError::Unsupported { version: value })
}
}

impl<E> VersionedPlan<E> {
#[must_use]
pub const fn new(plan: ExecutionPlan<E>) -> Self {
Self {
schema: PlanSchemaVersion::CURRENT,
plan,
}
}

#[must_use]
pub const fn schema(&self) -> PlanSchemaVersion {
self.schema
}

#[must_use]
pub const fn plan(&self) -> &ExecutionPlan<E> {
&self.plan
}

#[must_use]
pub fn into_plan(self) -> ExecutionPlan<E> {
self.plan
}
}
+86
View File
@@ -1,0 +1,86 @@
use std::fmt;
use std::str::FromStr;

use thiserror::Error;
use url::Url;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RepositoryUrl(Url);

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Revision(String);

#[derive(Debug, Error)]
pub enum SourceValueError {
#[error("repository URL is invalid: {0}")]
RepositoryUrl(#[from] url::ParseError),

#[error("repository URL scheme {0:?} is not supported")]
RepositoryScheme(String),

#[error("repository revision must not be empty")]
EmptyRevision,
}

impl FromStr for RepositoryUrl {
type Err = SourceValueError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
Url::parse(value)?.try_into()
}
}

impl TryFrom<Url> for RepositoryUrl {
type Error = SourceValueError;

fn try_from(url: Url) -> Result<Self, Self::Error> {
if !matches!(url.scheme(), "http" | "https" | "file") {
return Err(SourceValueError::RepositoryScheme(url.scheme().to_owned()));
}
Ok(Self(url))
}
}

impl AsRef<str> for RepositoryUrl {
fn as_ref(&self) -> &str {
self.0.as_str()
}
}

impl RepositoryUrl {
#[must_use]
pub fn same_origin(&self, other: &Self) -> bool {
self.0.scheme() == other.0.scheme()
&& self.0.host_str() == other.0.host_str()
&& self.0.port_or_known_default() == other.0.port_or_known_default()
}
}

impl fmt::Display for RepositoryUrl {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}

impl FromStr for Revision {
type Err = SourceValueError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty() {
return Err(SourceValueError::EmptyRevision);
}
Ok(Self(value.to_owned()))
}
}

impl AsRef<str> for Revision {
fn as_ref(&self) -> &str {
&self.0
}
}

impl fmt::Display for Revision {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
+82
View File
@@ -1,0 +1,82 @@
use std::fmt;
use std::str::FromStr;

use thiserror::Error;

/// Where a reusable workflow lives. A local call names a path in the same
/// commit the caller came from; a remote one names another repository at its
/// own revision, which has to be fetched before it can be read.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ReusableWorkflow {
Local {
path: String,
},
Remote {
repository: String,
path: String,
revision: String,
},
}

#[derive(Debug, Error)]
pub enum ReusableWorkflowError {
#[error("a reusable workflow call must name a path")]
Empty,

#[error("reusable workflow call {0:?} names a repository but no revision")]
MissingRevision(String),

#[error("reusable workflow call {0:?} does not name a workflow file")]
MissingPath(String),
}

impl FromStr for ReusableWorkflow {
type Err = ReusableWorkflowError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty() {
return Err(ReusableWorkflowError::Empty);
}
if let Some(path) = value.strip_prefix("./") {
if path.is_empty() {
return Err(ReusableWorkflowError::MissingPath(value.to_owned()));
}
return Ok(Self::Local {
path: path.to_owned(),
});
}
let (address, revision) = value
.split_once('@')
.ok_or_else(|| ReusableWorkflowError::MissingRevision(value.to_owned()))?;
if revision.is_empty() {
return Err(ReusableWorkflowError::MissingRevision(value.to_owned()));
}
let mut segments = address.splitn(3, '/');
let (Some(owner), Some(name), Some(path)) =
(segments.next(), segments.next(), segments.next())
else {
return Err(ReusableWorkflowError::MissingPath(value.to_owned()));
};
if owner.is_empty() || name.is_empty() || path.is_empty() {
return Err(ReusableWorkflowError::MissingPath(value.to_owned()));
}
Ok(Self::Remote {
repository: format!("{owner}/{name}"),
path: path.to_owned(),
revision: revision.to_owned(),
})
}
}

impl fmt::Display for ReusableWorkflow {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local { path } => write!(formatter, "./{path}"),
Self::Remote {
repository,
path,
revision,
} => write!(formatter, "{repository}/{path}@{revision}"),
}
}
}
+7
View File
@@ -1,0 +1,7 @@
/// JavaScript runtimes SynCode ships. Node 20 is the floor: older runtimes are
/// rejected when an action declares them, never silently upgraded.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum JavaScriptRuntime {
Node20,
Node24,
}
+195
View File
@@ -1,0 +1,195 @@
use std::fmt;

use thiserror::Error;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EventKind {
Push,
PullRequest,
Manual,
Schedule,
}

/// What happened, as much of it as deciding needs: which kind, on which branch,
/// and which paths it touched.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Event {
kind: EventKind,
branch: String,
changed_paths: Vec<String>,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Filter {
include: Vec<Pattern>,
exclude: Vec<Pattern>,
}

/// A subset of the glob syntax the dialect allows: literals, `*` within one
/// segment, and `**` across segments. Anything else is refused rather than
/// matched by accident.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Pattern(String);

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Trigger {
kind: EventKind,
branches: Filter,
paths: Filter,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Triggers(Vec<Trigger>);

#[derive(Debug, Error)]
pub enum TriggerError {
#[error("pattern {0:?} uses glob syntax this compiler does not support")]
UnsupportedPattern(String),
}

impl Event {
#[must_use]
pub fn new(kind: EventKind, branch: String, changed_paths: Vec<String>) -> Self {
Self {
kind,
branch,
changed_paths,
}
}

#[must_use]
pub const fn kind(&self) -> EventKind {
self.kind
}

#[must_use]
pub fn branch(&self) -> &str {
&self.branch
}
}

impl Pattern {
pub fn parse(value: &str) -> Result<Self, TriggerError> {
if value.contains(['?', '[', ']', '+', '!']) {
return Err(TriggerError::UnsupportedPattern(value.to_owned()));
}
Ok(Self(value.to_owned()))
}

#[must_use]
pub fn matches(&self, candidate: &str) -> bool {
matches_from(&self.0, candidate)
}
}

fn matches_from(pattern: &str, candidate: &str) -> bool {
match pattern.find('*') {
None => pattern == candidate,
Some(position) => {
let (literal, rest) = pattern.split_at(position);
if !candidate.starts_with(literal) {
return false;
}
let candidate = &candidate[literal.len()..];
if let Some(rest) = rest.strip_prefix("**") {
(0..=candidate.len()).any(|skip| matches_from(rest, &candidate[skip..]))
} else {
let rest = &rest[1..];
candidate
.char_indices()
.take_while(|(_, character)| *character != '/')
.map(|(index, character)| index + character.len_utf8())
.chain(std::iter::once(0))
.any(|skip| matches_from(rest, &candidate[skip..]))
}
}
}
}

impl Filter {
#[must_use]
pub const fn new(include: Vec<Pattern>, exclude: Vec<Pattern>) -> Self {
Self { include, exclude }
}

/// Nothing stated means everything passes. An exclusion always wins, which
/// is what makes `paths-ignore` mean what it says.
#[must_use]
pub fn admits(&self, candidate: &str) -> bool {
if self
.exclude
.iter()
.any(|pattern| pattern.matches(candidate))
{
return false;
}
self.include.is_empty()
|| self
.include
.iter()
.any(|pattern| pattern.matches(candidate))
}

#[must_use]
pub fn is_empty(&self) -> bool {
self.include.is_empty() && self.exclude.is_empty()
}
}

impl Trigger {
#[must_use]
pub const fn new(kind: EventKind, branches: Filter, paths: Filter) -> Self {
Self {
kind,
branches,
paths,
}
}

#[must_use]
pub fn fires_on(&self, event: &Event) -> bool {
if self.kind != event.kind {
return false;
}
if !self.branches.admits(&event.branch) {
return false;
}
// A path filter on an event that touched nothing has nothing to admit,
// so it does not fire.
self.paths.is_empty()
|| event
.changed_paths
.iter()
.any(|path| self.paths.admits(path))
}
}

impl Triggers {
#[must_use]
pub const fn new(triggers: Vec<Trigger>) -> Self {
Self(triggers)
}

/// A workflow with no `on:` is never triggered by an event. Saying nothing
/// is not saying everything.
#[must_use]
pub fn fire_on(&self, event: &Event) -> bool {
self.0.iter().any(|trigger| trigger.fires_on(event))
}

#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}

impl fmt::Display for EventKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Push => formatter.write_str("push"),
Self::PullRequest => formatter.write_str("pull_request"),
Self::Manual => formatter.write_str("workflow_dispatch"),
Self::Schedule => formatter.write_str("schedule"),
}
}
}
+56
View File
@@ -1,0 +1,56 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow::ReusableWorkflow;

#[test]
fn a_local_call_names_a_path_in_the_same_commit() {
let call: ReusableWorkflow = "./.gitea/workflows/build.yml"
.parse()
.unwrap_or_else(|error| panic!("parse: {error}"));

assert_eq!(
call,
ReusableWorkflow::Local {
path: ".gitea/workflows/build.yml".to_owned()
}
);
assert_eq!(call.to_string(), "./.gitea/workflows/build.yml");
}

#[test]
fn a_remote_call_names_a_repository_a_path_and_a_revision() {
let call: ReusableWorkflow = "syncode/meta/.gitea/workflows/build.yml@main"
.parse()
.unwrap_or_else(|error| panic!("parse: {error}"));

assert_eq!(
call,
ReusableWorkflow::Remote {
repository: "syncode/meta".to_owned(),
path: ".gitea/workflows/build.yml".to_owned(),
revision: "main".to_owned(),
}
);
}

#[test]
fn a_remote_call_without_a_revision_is_refused() {
let error = "syncode/meta/.gitea/workflows/build.yml"
.parse::<ReusableWorkflow>()
.expect_err("a repository without a revision cannot be fetched");

assert!(
error.to_string().contains("no revision"),
"unexpected error: {error}"
);
}

#[test]
fn a_call_that_names_no_workflow_file_is_refused() {
for value in ["syncode/meta@main", "./", ""] {
assert!(
value.parse::<ReusableWorkflow>().is_err(),
"{value:?} names no workflow file and must be refused"
);
}
}
+122
View File
@@ -1,0 +1,122 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use syncode_workflow::{Event, EventKind, Filter, Pattern, Trigger, Triggers};

fn pattern(value: &str) -> Pattern {
Pattern::parse(value).unwrap_or_else(|error| panic!("pattern {value}: {error}"))
}

fn patterns(values: &[&str]) -> Vec<Pattern> {
values.iter().copied().map(pattern).collect()
}

fn push(branch: &str, paths: &[&str]) -> Event {
Event::new(
EventKind::Push,
branch.to_owned(),
paths.iter().map(|path| (*path).to_owned()).collect(),
)
}

#[test]
fn a_workflow_that_says_nothing_is_never_triggered() {
assert!(!Triggers::default().fire_on(&push("main", &["src/main.rs"])));
}

#[test]
fn a_branch_filter_decides_which_pushes_count() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["main", "release/*"]), Vec::new()),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("main", &[])));
assert!(triggers.fire_on(&push("release/0.3", &[])));
assert!(!triggers.fire_on(&push("feature/x", &[])));
assert!(
!triggers.fire_on(&push("release/0.3/hotfix", &[])),
"a single star does not cross a slash"
);
}

#[test]
fn a_double_star_crosses_slashes() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["release/**"]), Vec::new()),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("release/0.3", &[])));
assert!(triggers.fire_on(&push("release/0.3/hotfix", &[])));
}

#[test]
fn an_exclusion_wins_over_an_inclusion() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::new(patterns(&["**"]), patterns(&["wip/**"])),
Filter::default(),
)]);

assert!(triggers.fire_on(&push("main", &[])));
assert!(!triggers.fire_on(&push("wip/experiment", &[])));
}

#[test]
fn a_path_filter_needs_a_touched_path_to_admit() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::default(),
Filter::new(patterns(&["crates/**"]), Vec::new()),
)]);

assert!(triggers.fire_on(&push("main", &["crates/workflow/src/lib.rs"])));
assert!(!triggers.fire_on(&push("main", &["README.md"])));
assert!(
!triggers.fire_on(&push("main", &[])),
"an event that touched nothing has nothing for a path filter to admit"
);
}

#[test]
fn ignored_paths_do_not_trigger_on_their_own() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::Push,
Filter::default(),
Filter::new(Vec::new(), patterns(&["**.md"])),
)]);

assert!(!triggers.fire_on(&push("main", &["README.md"])));
assert!(
triggers.fire_on(&push("main", &["README.md", "src/main.rs"])),
"one path outside the ignore list is enough"
);
}

#[test]
fn an_event_of_another_kind_does_not_fire_the_trigger() {
let triggers = Triggers::new(vec![Trigger::new(
EventKind::PullRequest,
Filter::default(),
Filter::default(),
)]);

assert!(!triggers.fire_on(&push("main", &["src/main.rs"])));
assert!(triggers.fire_on(&Event::new(
EventKind::PullRequest,
"main".to_owned(),
vec!["src/main.rs".to_owned()],
)));
}

#[test]
fn glob_syntax_this_compiler_does_not_support_is_refused() {
let error = Pattern::parse("release/[0-9]*").expect_err("character classes are not supported");

assert!(
error.to_string().contains("does not support"),
"unexpected error: {error}"
);
}