Align trigger preflight with released workflow API #16
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,6 +1,0 @@
|
||||
**/.git
|
||||
**/target
|
||||
.idea
|
||||
.vscode
|
||||
*.md
|
||||
LICENSE
|
||||
-14
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user