Align trigger preflight with released workflow API #16
+2
-2
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,18 +1,18 @@
|
||||
[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"
|
||||
[workspace]
|
||||
members = ["crates/workflow", "crates/workflow-github-actions"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.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,31 +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
|
||||
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
|
||||
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@11d5960a326750d5838078e36cf38b85af677262
|
||||
|
||||
- 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,107 +1,164 @@
|
||||
#![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}"
|
||||
);
|
||||
}
|
||||
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
|
||||
#[path = "support/workflow.rs"]
|
||||
mod workflow;
|
||||
|
||||
use syncode_workflow::{
|
||||
ActionReference, GitCommit, PlanSchemaVersion, ResolvedAction, ResolvedRemoteAction,
|
||||
Sha256Digest, StepKind, 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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resolved_remote_action_carries_immutable_identity() {
|
||||
let mut execution = compile(WORKFLOW).unwrap_or_else(|error| panic!("{error}"));
|
||||
let action = execution
|
||||
.job_mut()
|
||||
.steps_mut()
|
||||
.iter_mut()
|
||||
.find_map(|step| match step.kind_mut() {
|
||||
StepKind::Action(action) => Some(action),
|
||||
StepKind::Shell(_) => None,
|
||||
})
|
||||
.expect("action step");
|
||||
action.resolve(ResolvedAction::Remote(ResolvedRemoteAction::new(
|
||||
"https://dev.syncode.sh/actions/checkout"
|
||||
.parse()
|
||||
.expect("repository URL"),
|
||||
"actions/checkout@v4"
|
||||
.parse::<ActionReference>()
|
||||
.expect("requested reference"),
|
||||
"0123456789abcdef0123456789abcdef01234567"
|
||||
.parse::<GitCommit>()
|
||||
.expect("commit"),
|
||||
"".parse().expect("action path"),
|
||||
"a".repeat(64)
|
||||
.parse::<Sha256Digest>()
|
||||
.expect("archive digest"),
|
||||
"b".repeat(64)
|
||||
.parse::<Sha256Digest>()
|
||||
.expect("metadata digest"),
|
||||
Vec::new(),
|
||||
)));
|
||||
let plan = VersionedPlan::new(execution);
|
||||
let encoded = serde_json::to_string(&plan).expect("encode");
|
||||
let decoded: VersionedPlan<ExpressionProgram> = serde_json::from_str(&encoded).expect("decode");
|
||||
|
||||
assert_eq!(decoded, plan);
|
||||
assert!(encoded.contains("0123456789abcdef0123456789abcdef01234567"));
|
||||
assert!(encoded.contains(&"a".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_previous_plan_schema_is_rejected() {
|
||||
let plan = VersionedPlan::new(compile(WORKFLOW).expect("compile"));
|
||||
let encoded = serde_json::to_string(&plan).expect("encode");
|
||||
let forged = encoded.replace(
|
||||
&format!("\"schema\":{}", u32::from(PlanSchemaVersion::CURRENT)),
|
||||
"\"schema\":1",
|
||||
);
|
||||
|
||||
let error = serde_json::from_str::<VersionedPlan<ExpressionProgram>>(&forged)
|
||||
.expect_err("schema v1 must not decode");
|
||||
assert!(error.to_string().contains("is not supported"));
|
||||
}
|
||||
@@ -1,133 +1,158 @@
|
||||
#![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 { .. }));
|
||||
}
|
||||
#![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_reusable_workflow_outputs() {
|
||||
let node = parse(
|
||||
r#"
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
result:
|
||||
value: ${{ jobs.verify.outputs.result }}
|
||||
jobs:
|
||||
verify:
|
||||
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.outputs.get("result").map(String::as_str),
|
||||
Some("${{ jobs.verify.outputs.result }}")
|
||||
);
|
||||
}
|
||||
|
||||
#[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,41 +1,46 @@
|
||||
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,
|
||||
};
|
||||
mod action;
|
||||
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 action::{
|
||||
ActionIdentityError, ActionPath, ActionReference, GitCommit, OciDigest, PlannedAction,
|
||||
ResolvedAction, ResolvedLocalAction, ResolvedOciAction, ResolvedRemoteAction, Sha256Digest,
|
||||
};
|
||||
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,13 +1,14 @@
|
||||
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};
|
||||
mod access;
|
||||
mod action;
|
||||
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,86 +1,107 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
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 Serialize for RepositoryUrl {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RepositoryUrl {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer)?
|
||||
.parse()
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
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,191 +1,197 @@
|
||||
mod json;
|
||||
|
||||
use super::ast::Comparison;
|
||||
use super::builtin::BuiltinFunction;
|
||||
use super::eval::{compare, to_string};
|
||||
use super::{EvaluationContext, EvaluationStatus, ExpressionError};
|
||||
use syncode_workflow::Value;
|
||||
|
||||
pub fn call(
|
||||
function: BuiltinFunction,
|
||||
arguments: &[Value],
|
||||
context: &EvaluationContext,
|
||||
) -> Result<Value, ExpressionError> {
|
||||
let name = function.as_ref();
|
||||
match function {
|
||||
BuiltinFunction::Always => status(name, arguments, true),
|
||||
BuiltinFunction::Success => status(
|
||||
name,
|
||||
arguments,
|
||||
context.status() == EvaluationStatus::Success,
|
||||
),
|
||||
BuiltinFunction::Failure => status(
|
||||
name,
|
||||
arguments,
|
||||
context.status() == EvaluationStatus::Failure,
|
||||
),
|
||||
BuiltinFunction::Cancelled => status(
|
||||
name,
|
||||
arguments,
|
||||
context.status() == EvaluationStatus::Cancelled,
|
||||
),
|
||||
BuiltinFunction::Contains => contains(name, arguments),
|
||||
BuiltinFunction::StartsWith => starts_with(name, arguments),
|
||||
BuiltinFunction::EndsWith => ends_with(name, arguments),
|
||||
BuiltinFunction::Format => format_value(name, arguments),
|
||||
BuiltinFunction::Join => join(name, arguments),
|
||||
BuiltinFunction::ToJson => json::to_json(name, arguments),
|
||||
BuiltinFunction::FromJson => json::from_json(name, arguments),
|
||||
BuiltinFunction::HashFiles => Err(ExpressionError::HashFilesUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
fn status(name: &str, arguments: &[Value], value: bool) -> Result<Value, ExpressionError> {
|
||||
exact_arguments(name, arguments, 0)?;
|
||||
Ok(Value::Bool(value))
|
||||
}
|
||||
|
||||
fn contains(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
exact_arguments(name, arguments, 2)?;
|
||||
let contains = match &arguments[0] {
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.any(|value| compare(value, &arguments[1], Comparison::Equal).unwrap_or(false)),
|
||||
search => to_string(search)
|
||||
.to_ascii_lowercase()
|
||||
.contains(&to_string(&arguments[1]).to_ascii_lowercase()),
|
||||
};
|
||||
Ok(Value::Bool(contains))
|
||||
}
|
||||
|
||||
fn starts_with(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
exact_arguments(name, arguments, 2)?;
|
||||
Ok(Value::Bool(
|
||||
to_string(&arguments[0])
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&to_string(&arguments[1]).to_ascii_lowercase()),
|
||||
))
|
||||
}
|
||||
|
||||
fn ends_with(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
exact_arguments(name, arguments, 2)?;
|
||||
Ok(Value::Bool(
|
||||
to_string(&arguments[0])
|
||||
.to_ascii_lowercase()
|
||||
.ends_with(&to_string(&arguments[1]).to_ascii_lowercase()),
|
||||
))
|
||||
}
|
||||
|
||||
fn format_value(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
at_least_arguments(name, arguments, 1)?;
|
||||
let format = to_string(&arguments[0]);
|
||||
let replacements = &arguments[1..];
|
||||
let mut output = String::new();
|
||||
let mut characters = format.char_indices().peekable();
|
||||
while let Some((position, character)) = characters.next() {
|
||||
match character {
|
||||
'{' if characters.peek().is_some_and(|(_, next)| *next == '{') => {
|
||||
characters.next();
|
||||
output.push('{');
|
||||
}
|
||||
'}' if characters.peek().is_some_and(|(_, next)| *next == '}') => {
|
||||
characters.next();
|
||||
output.push('}');
|
||||
}
|
||||
'{' => {
|
||||
let mut index = String::new();
|
||||
loop {
|
||||
match characters.next() {
|
||||
Some((_, '}')) => break,
|
||||
Some((_, digit)) if digit.is_ascii_digit() => index.push(digit),
|
||||
_ => return Err(ExpressionError::InvalidFormat(format.clone())),
|
||||
}
|
||||
}
|
||||
let index = index
|
||||
.parse::<usize>()
|
||||
.map_err(|_| ExpressionError::InvalidFormat(format.clone()))?;
|
||||
let replacement = replacements
|
||||
.get(index)
|
||||
.ok_or_else(|| ExpressionError::InvalidFormat(format.clone()))?;
|
||||
output.push_str(&to_string(replacement));
|
||||
}
|
||||
'}' => {
|
||||
let _ = position;
|
||||
return Err(ExpressionError::InvalidFormat(format));
|
||||
}
|
||||
value => output.push(value),
|
||||
}
|
||||
}
|
||||
Ok(Value::String(output))
|
||||
}
|
||||
|
||||
fn join(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
argument_range(name, arguments, 1, 2)?;
|
||||
let separator = arguments.get(1).map_or_else(|| ",".to_owned(), to_string);
|
||||
let joined = match &arguments[0] {
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.map(to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(&separator),
|
||||
value => to_string(value),
|
||||
};
|
||||
Ok(Value::String(joined))
|
||||
}
|
||||
|
||||
pub(super) fn exact_arguments(
|
||||
function: &str,
|
||||
arguments: &[Value],
|
||||
expected: usize,
|
||||
) -> Result<(), ExpressionError> {
|
||||
if arguments.len() == expected {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ExpressionError::InvalidArgumentCount {
|
||||
function: function.to_owned(),
|
||||
expected: expected.to_string(),
|
||||
actual: arguments.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn at_least_arguments(
|
||||
function: &str,
|
||||
arguments: &[Value],
|
||||
minimum: usize,
|
||||
) -> Result<(), ExpressionError> {
|
||||
if arguments.len() >= minimum {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ExpressionError::InvalidArgumentCount {
|
||||
function: function.to_owned(),
|
||||
expected: format!("at least {minimum}"),
|
||||
actual: arguments.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn argument_range(
|
||||
function: &str,
|
||||
arguments: &[Value],
|
||||
minimum: usize,
|
||||
maximum: usize,
|
||||
) -> Result<(), ExpressionError> {
|
||||
if (minimum..=maximum).contains(&arguments.len()) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ExpressionError::InvalidArgumentCount {
|
||||
function: function.to_owned(),
|
||||
expected: format!("{minimum}..={maximum}"),
|
||||
actual: arguments.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn value_type(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
mod json;
|
||||
|
||||
use super::ast::Comparison;
|
||||
use super::builtin::BuiltinFunction;
|
||||
use super::eval::{compare, to_string};
|
||||
use super::{EvaluationContext, EvaluationStatus, ExpressionError};
|
||||
use syncode_workflow::Value;
|
||||
|
||||
pub fn call(
|
||||
function: BuiltinFunction,
|
||||
arguments: &[Value],
|
||||
context: &EvaluationContext,
|
||||
) -> Result<Value, ExpressionError> {
|
||||
let name = function.as_ref();
|
||||
match function {
|
||||
BuiltinFunction::Always => status(name, arguments, true),
|
||||
BuiltinFunction::Success => status(
|
||||
name,
|
||||
arguments,
|
||||
context.status() == EvaluationStatus::Success,
|
||||
),
|
||||
BuiltinFunction::Failure => status(
|
||||
name,
|
||||
arguments,
|
||||
context.status() == EvaluationStatus::Failure,
|
||||
),
|
||||
BuiltinFunction::Cancelled => status(
|
||||
name,
|
||||
arguments,
|
||||
context.status() == EvaluationStatus::Cancelled,
|
||||
),
|
||||
BuiltinFunction::Contains => contains(name, arguments),
|
||||
BuiltinFunction::StartsWith => starts_with(name, arguments),
|
||||
BuiltinFunction::EndsWith => ends_with(name, arguments),
|
||||
BuiltinFunction::Format => format_value(name, arguments),
|
||||
BuiltinFunction::Join => join(name, arguments),
|
||||
BuiltinFunction::ToJson => json::to_json(name, arguments),
|
||||
BuiltinFunction::FromJson => json::from_json(name, arguments),
|
||||
BuiltinFunction::HashFiles => Err(ExpressionError::HashFilesUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
fn status(name: &str, arguments: &[Value], value: bool) -> Result<Value, ExpressionError> {
|
||||
exact_arguments(name, arguments, 0)?;
|
||||
Ok(Value::Bool(value))
|
||||
}
|
||||
|
||||
fn contains(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
exact_arguments(name, arguments, 2)?;
|
||||
let contains = match &arguments[0] {
|
||||
Value::Array(values) => {
|
||||
let mut found = false;
|
||||
for value in values.iter() {
|
||||
if compare(value, &arguments[1], Comparison::Equal)? {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
search => to_string(search)
|
||||
.to_ascii_lowercase()
|
||||
.contains(&to_string(&arguments[1]).to_ascii_lowercase()),
|
||||
};
|
||||
Ok(Value::Bool(contains))
|
||||
}
|
||||
|
||||
fn starts_with(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
exact_arguments(name, arguments, 2)?;
|
||||
Ok(Value::Bool(
|
||||
to_string(&arguments[0])
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&to_string(&arguments[1]).to_ascii_lowercase()),
|
||||
))
|
||||
}
|
||||
|
||||
fn ends_with(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
exact_arguments(name, arguments, 2)?;
|
||||
Ok(Value::Bool(
|
||||
to_string(&arguments[0])
|
||||
.to_ascii_lowercase()
|
||||
.ends_with(&to_string(&arguments[1]).to_ascii_lowercase()),
|
||||
))
|
||||
}
|
||||
|
||||
fn format_value(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
at_least_arguments(name, arguments, 1)?;
|
||||
let format = to_string(&arguments[0]);
|
||||
let replacements = &arguments[1..];
|
||||
let mut output = String::new();
|
||||
let mut characters = format.char_indices().peekable();
|
||||
while let Some((_, character)) = characters.next() {
|
||||
match character {
|
||||
'{' if characters.peek().is_some_and(|(_, next)| *next == '{') => {
|
||||
characters.next();
|
||||
output.push('{');
|
||||
}
|
||||
'}' if characters.peek().is_some_and(|(_, next)| *next == '}') => {
|
||||
characters.next();
|
||||
output.push('}');
|
||||
}
|
||||
'{' => {
|
||||
let mut index = String::new();
|
||||
loop {
|
||||
match characters.next() {
|
||||
Some((_, '}')) => break,
|
||||
Some((_, digit)) if digit.is_ascii_digit() => index.push(digit),
|
||||
_ => return Err(ExpressionError::InvalidFormat(format.clone())),
|
||||
}
|
||||
}
|
||||
let index = index
|
||||
.parse::<usize>()
|
||||
.map_err(|_| ExpressionError::InvalidFormat(format.clone()))?;
|
||||
let replacement = replacements
|
||||
.get(index)
|
||||
.ok_or_else(|| ExpressionError::InvalidFormat(format.clone()))?;
|
||||
output.push_str(&to_string(replacement));
|
||||
}
|
||||
'}' => {
|
||||
return Err(ExpressionError::InvalidFormat(format));
|
||||
}
|
||||
value => output.push(value),
|
||||
}
|
||||
}
|
||||
Ok(Value::String(output))
|
||||
}
|
||||
|
||||
fn join(name: &str, arguments: &[Value]) -> Result<Value, ExpressionError> {
|
||||
argument_range(name, arguments, 1, 2)?;
|
||||
let separator = arguments.get(1).map_or_else(|| ",".to_owned(), to_string);
|
||||
let joined = match &arguments[0] {
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.map(to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(&separator),
|
||||
value => to_string(value),
|
||||
};
|
||||
Ok(Value::String(joined))
|
||||
}
|
||||
|
||||
pub(super) fn exact_arguments(
|
||||
function: &str,
|
||||
arguments: &[Value],
|
||||
expected: usize,
|
||||
) -> Result<(), ExpressionError> {
|
||||
if arguments.len() == expected {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ExpressionError::InvalidArgumentCount {
|
||||
function: function.to_owned(),
|
||||
expected: expected.to_string(),
|
||||
actual: arguments.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn at_least_arguments(
|
||||
function: &str,
|
||||
arguments: &[Value],
|
||||
minimum: usize,
|
||||
) -> Result<(), ExpressionError> {
|
||||
if arguments.len() >= minimum {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ExpressionError::InvalidArgumentCount {
|
||||
function: function.to_owned(),
|
||||
expected: format!("at least {minimum}"),
|
||||
actual: arguments.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn argument_range(
|
||||
function: &str,
|
||||
arguments: &[Value],
|
||||
minimum: usize,
|
||||
maximum: usize,
|
||||
) -> Result<(), ExpressionError> {
|
||||
if (minimum..=maximum).contains(&arguments.len()) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ExpressionError::InvalidArgumentCount {
|
||||
function: function.to_owned(),
|
||||
expected: format!("{minimum}..={maximum}"),
|
||||
actual: arguments.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn value_type(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,57 @@
|
||||
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 },
|
||||
}
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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 outputs: BTreeMap<String, String>,
|
||||
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,165 +1,194 @@
|
||||
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",
|
||||
})
|
||||
}
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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(),
|
||||
outputs: read_workflow_outputs(root)?,
|
||||
jobs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn read_workflow_outputs(root: &Node) -> Result<BTreeMap<String, String>, WorkflowModelError> {
|
||||
let Some(call) = root.get("on").and_then(|node| node.get("workflow_call")) else {
|
||||
return Ok(BTreeMap::new());
|
||||
};
|
||||
let Some(outputs) = call.get("outputs") else {
|
||||
return Ok(BTreeMap::new());
|
||||
};
|
||||
mapping(outputs, "$.on.workflow_call.outputs")?
|
||||
.iter()
|
||||
.map(|(name, output)| {
|
||||
let path = format!("$.on.workflow_call.outputs.{name}.value");
|
||||
let value = required(
|
||||
output,
|
||||
"value",
|
||||
&format!("$.on.workflow_call.outputs.{name}"),
|
||||
)?
|
||||
.as_str()
|
||||
.ok_or(WorkflowModelError::Expected {
|
||||
path,
|
||||
expected: "a string",
|
||||
})?;
|
||||
Ok((name.clone(), value.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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,230 +1,227 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
use super::model::{
|
||||
ExecutionPlan, JobPlan, JobPlanParts, PlanError, ShellStep, StepKind, StepPlan, StepPlanParts,
|
||||
};
|
||||
use crate::{
|
||||
BooleanValue, Defaults, EnvironmentBinding, 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
|
||||
}
|
||||
|
||||
pub fn job_mut(&mut self) -> &mut JobPlan<E> {
|
||||
&mut 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
|
||||
}
|
||||
|
||||
pub fn steps_mut(&mut self) -> &mut [StepPlan<E>] {
|
||||
&mut 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
|
||||
}
|
||||
|
||||
pub fn kind_mut(&mut self) -> &mut StepKind<E> {
|
||||
&mut 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()
|
||||
}
|
||||
}
|
||||
@@ -1,103 +1,103 @@
|
||||
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,
|
||||
}
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
BooleanValue, ContainerDefinition, Defaults, EnvironmentBinding, InputBinding, JobKey,
|
||||
JobStrategy, OutputBinding, PlannedAction, 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) source: PlannedAction<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,78 +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
|
||||
}
|
||||
}
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ExecutionPlan;
|
||||
|
||||
const CURRENT: u32 = 2;
|
||||
|
||||
#[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,172 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RepositoryUrl, Template};
|
||||
|
||||
mod identity;
|
||||
|
||||
pub use identity::{
|
||||
ActionIdentityError, ActionPath, ActionReference, GitCommit, OciDigest, Sha256Digest,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "state")]
|
||||
pub enum PlannedAction<E> {
|
||||
Unresolved { reference: Template<E> },
|
||||
Resolved { source: Box<ResolvedAction> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum ResolvedAction {
|
||||
Local(ResolvedLocalAction),
|
||||
Remote(ResolvedRemoteAction),
|
||||
Oci(ResolvedOciAction),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ResolvedLocalAction {
|
||||
requested_reference: ActionReference,
|
||||
path: ActionPath,
|
||||
dependencies: Vec<ResolvedAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ResolvedRemoteAction {
|
||||
repository: RepositoryUrl,
|
||||
requested_reference: ActionReference,
|
||||
commit: GitCommit,
|
||||
path: ActionPath,
|
||||
archive_digest: Sha256Digest,
|
||||
metadata_digest: Sha256Digest,
|
||||
dependencies: Vec<ResolvedAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ResolvedOciAction {
|
||||
requested_reference: ActionReference,
|
||||
manifest_digest: OciDigest,
|
||||
}
|
||||
|
||||
impl<E> PlannedAction<E> {
|
||||
#[must_use]
|
||||
pub fn unresolved(reference: Template<E>) -> Self {
|
||||
Self::Unresolved { reference }
|
||||
}
|
||||
|
||||
pub const fn unresolved_reference(&self) -> Option<&Template<E>> {
|
||||
match self {
|
||||
Self::Unresolved { reference } => Some(reference),
|
||||
Self::Resolved { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved_source(&self) -> Option<&ResolvedAction> {
|
||||
match self {
|
||||
Self::Unresolved { .. } => None,
|
||||
Self::Resolved { source } => Some(source.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(&mut self, source: ResolvedAction) {
|
||||
*self = Self::Resolved {
|
||||
source: Box::new(source),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedRemoteAction {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repository: RepositoryUrl,
|
||||
requested_reference: ActionReference,
|
||||
commit: GitCommit,
|
||||
path: ActionPath,
|
||||
archive_digest: Sha256Digest,
|
||||
metadata_digest: Sha256Digest,
|
||||
dependencies: Vec<ResolvedAction>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
requested_reference,
|
||||
commit,
|
||||
path,
|
||||
archive_digest,
|
||||
metadata_digest,
|
||||
dependencies,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn repository(&self) -> &RepositoryUrl {
|
||||
&self.repository
|
||||
}
|
||||
|
||||
pub const fn requested_reference(&self) -> &ActionReference {
|
||||
&self.requested_reference
|
||||
}
|
||||
|
||||
pub const fn commit(&self) -> &GitCommit {
|
||||
&self.commit
|
||||
}
|
||||
|
||||
pub const fn path(&self) -> &ActionPath {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub const fn archive_digest(&self) -> &Sha256Digest {
|
||||
&self.archive_digest
|
||||
}
|
||||
|
||||
pub const fn metadata_digest(&self) -> &Sha256Digest {
|
||||
&self.metadata_digest
|
||||
}
|
||||
|
||||
pub fn dependencies(&self) -> &[ResolvedAction] {
|
||||
&self.dependencies
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedLocalAction {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
requested_reference: ActionReference,
|
||||
path: ActionPath,
|
||||
dependencies: Vec<ResolvedAction>,
|
||||
) -> Self {
|
||||
Self {
|
||||
requested_reference,
|
||||
path,
|
||||
dependencies,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn requested_reference(&self) -> &ActionReference {
|
||||
&self.requested_reference
|
||||
}
|
||||
|
||||
pub const fn path(&self) -> &ActionPath {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn dependencies(&self) -> &[ResolvedAction] {
|
||||
&self.dependencies
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedOciAction {
|
||||
#[must_use]
|
||||
pub const fn new(requested_reference: ActionReference, manifest_digest: OciDigest) -> Self {
|
||||
Self {
|
||||
requested_reference,
|
||||
manifest_digest,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn requested_reference(&self) -> &ActionReference {
|
||||
&self.requested_reference
|
||||
}
|
||||
|
||||
pub const fn manifest_digest(&self) -> &OciDigest {
|
||||
&self.manifest_digest
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,162 @@
|
||||
use std::fmt;
|
||||
use std::path::{Component, Path};
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ActionReference(String);
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ActionPath(String);
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct GitCommit(String);
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Sha256Digest(String);
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct OciDigest(String);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ActionIdentityError {
|
||||
#[error("action reference must not be empty")]
|
||||
EmptyReference,
|
||||
#[error("action path must be relative and must not traverse its repository")]
|
||||
InvalidPath,
|
||||
#[error("Git commit must contain exactly 40 lowercase hexadecimal characters")]
|
||||
InvalidCommit,
|
||||
#[error("SHA-256 digest must contain exactly 64 lowercase hexadecimal characters")]
|
||||
InvalidSha256,
|
||||
#[error("OCI digest must use the sha256 algorithm")]
|
||||
InvalidOciDigest,
|
||||
}
|
||||
|
||||
impl FromStr for ActionReference {
|
||||
type Err = ActionIdentityError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if value.is_empty() {
|
||||
return Err(ActionIdentityError::EmptyReference);
|
||||
}
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ActionPath {
|
||||
type Err = ActionIdentityError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
let path = Path::new(value);
|
||||
if path.is_absolute()
|
||||
|| path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(ActionIdentityError::InvalidPath);
|
||||
}
|
||||
Ok(Self(value.trim_matches('/').to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for GitCommit {
|
||||
type Err = ActionIdentityError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if value.len() != 40 || !lower_hex(value) {
|
||||
return Err(ActionIdentityError::InvalidCommit);
|
||||
}
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Sha256Digest {
|
||||
type Err = ActionIdentityError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if value.len() != 64 || !lower_hex(value) {
|
||||
return Err(ActionIdentityError::InvalidSha256);
|
||||
}
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for OciDigest {
|
||||
type Err = ActionIdentityError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
let digest = value
|
||||
.strip_prefix("sha256:")
|
||||
.ok_or(ActionIdentityError::InvalidOciDigest)?;
|
||||
digest
|
||||
.parse::<Sha256Digest>()
|
||||
.map_err(|_| ActionIdentityError::InvalidOciDigest)?;
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_hex(value: &str) -> bool {
|
||||
value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||
}
|
||||
|
||||
macro_rules! string_value {
|
||||
($name:ident) => {
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer)?
|
||||
.parse()
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for $name {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
string_value!(ActionReference);
|
||||
string_value!(ActionPath);
|
||||
string_value!(GitCommit);
|
||||
string_value!(Sha256Digest);
|
||||
string_value!(OciDigest);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn immutable_identifiers_reject_ambiguous_values() {
|
||||
assert!(
|
||||
"ABCDEF0123456789abcdef0123456789abcdef01"
|
||||
.parse::<GitCommit>()
|
||||
.is_err()
|
||||
);
|
||||
assert!("../action".parse::<ActionPath>().is_err());
|
||||
assert!("sha512:deadbeef".parse::<OciDigest>().is_err());
|
||||
assert!("a".repeat(64).parse::<Sha256Digest>().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,0 +1,27 @@
|
||||
use super::model::ActionStep;
|
||||
use crate::{InputBinding, PlannedAction, ResolvedAction, Template};
|
||||
|
||||
impl<E> ActionStep<E> {
|
||||
pub fn new(reference: Template<E>, inputs: Vec<InputBinding<E>>) -> Self {
|
||||
Self {
|
||||
source: PlannedAction::unresolved(reference),
|
||||
inputs,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn source(&self) -> &PlannedAction<E> {
|
||||
&self.source
|
||||
}
|
||||
|
||||
pub const fn reference(&self) -> Option<&Template<E>> {
|
||||
self.source.unresolved_reference()
|
||||
}
|
||||
|
||||
pub fn resolve(&mut self, source: ResolvedAction) {
|
||||
self.source.resolve(source);
|
||||
}
|
||||
|
||||
pub fn inputs(&self) -> &[InputBinding<E>] {
|
||||
&self.inputs
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user