Align trigger preflight with released workflow API #17

Manually merged
day01 merged 8 commits from feat/0.6-trigger-preflight-aligned into develop 2026-08-31 07:59:37 +00:00
17 changed files with 537 additions and 32 deletions
Showing only changes of commit 6189ee6672 - Show all commits
+2 -2
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 -1
View File
@@ -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 { .. }));
}
+5
View File
@@ -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
View File
@@ -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};
+21
View File
@@ -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",
})
}
+15 -18
View File
@@ -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()
}
}
+3 -3
View File
@@ -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 -1
View File
@@ -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
}
}
+172
View File
@@ -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
}
}
+162
View File
@@ -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());
}
}
+27
View File
@@ -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
}
}