Skip to content

Commit 20490ef

Browse files
committed
feat(workflow): implement staging-deploy CI workflow with external approval
- Add `staging-deploy` workflow profile to default configuration - Add `--apply` CLI argument for explicit mutation intent - Implement strict CI mutation gates in `runner.rs` (requires `--apply`, `external` approval, and valid `deploy_context`) - Support explicit `external` approval metadata rendering in `workflow plan` and `workflow explain` - Add `staging-deploy` manual job to `.github/workflows/sailr-ci.yml`
1 parent 1c61534 commit 20490ef

8 files changed

Lines changed: 185 additions & 27 deletions

File tree

.github/workflows/sailr-ci.yml

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ on:
1010
description: "Run experimental ci-build profile"
1111
required: false
1212
default: "false"
13-
13+
run_staging_deploy:
14+
description: "Run staging deploy workflow"
15+
required: false
16+
default: "false"
1417
jobs:
1518
sailr-workflow:
1619
runs-on: ubuntu-latest
@@ -51,4 +54,30 @@ jobs:
5154

5255
- name: Run experimental CI build
5356
run: sailr workflow run ci-build --non-interactive
57+
58+
staging-deploy:
59+
if: github.event_name == 'workflow_dispatch' && inputs.run_staging_deploy == 'true'
60+
environment: staging
61+
runs-on: ubuntu-latest
62+
63+
steps:
64+
- uses: actions/checkout@v4
65+
66+
- name: Install Sailr
67+
run: cargo install --path .
68+
69+
- name: Configure kubeconfig
70+
run: |
71+
mkdir -p ~/.kube
72+
echo "${{ secrets.STAGING_KUBECONFIG }}" > ~/.kube/config
73+
chmod 600 ~/.kube/config
74+
75+
- name: Run staging deploy
76+
run: sailr workflow run staging-deploy --non-interactive --apply
77+
78+
- name: Upload Sailr report
79+
uses: actions/upload-artifact@v4
80+
with:
81+
name: sailr-staging-deploy-report
82+
path: .sailr/reports/staging-deploy/latest.json
5483

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
schema_version = "0.5.0"
2+
name = "staging"
3+
log_level = "INFO"
4+
domain = "staging.example.local"
5+
default_replicas = 1
6+
registry = "docker.io"
7+
8+
[[service]]
9+
name = "hello-sailr"
10+
version = "ci"
11+
path = "hello-sailr"
12+
13+
[[service]]
14+
name = "ci-build-hello"
15+
version = "ci"
16+
path = "examples/ci-build/hello"

sailr.workflow.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,16 @@ namespace = "default"
5959
approval = "none"
6060
apply = false
6161
report = "both"
62+
63+
[workflow.staging-deploy]
64+
environment = "staging"
65+
mode = "deploy"
66+
interactive = false
67+
build = "plan"
68+
generate = "run"
69+
deploy = "run"
70+
deploy_context = "staging"
71+
namespace = "default"
72+
approval = "external"
73+
apply = true
74+
report = "both"

src/cli.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ pub struct WorkflowRunArgs {
152152

153153
#[arg(long)]
154154
pub dry_run: bool,
155+
156+
#[arg(long)]
157+
pub apply: bool,
155158
}
156159

157160
#[derive(Debug, Args)]

src/default_config.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,16 @@ namespace = "default"
3232
approval = "none"
3333
apply = false
3434
report = "both"
35+
36+
[workflow.staging-deploy]
37+
environment = "staging"
38+
mode = "deploy"
39+
interactive = false
40+
build = "plan"
41+
generate = "run"
42+
deploy = "run"
43+
deploy_context = "staging"
44+
namespace = "default"
45+
approval = "external"
46+
apply = true
47+
report = "both"

src/workflow/profile.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -754,10 +754,10 @@ mod tests {
754754
"#;
755755
let profile: WorkflowProfile = toml::from_str(toml_str).unwrap();
756756
let normalized = profile.normalize(false); // not CI
757-
assert_eq!(normalized.interactive, true);
757+
assert!(normalized.interactive);
758758
assert_eq!(normalized.deploy, WorkflowStepMode::Run);
759759
assert_eq!(normalized.approval, ApprovalMode::Prompt);
760-
assert_eq!(normalized.apply, true);
760+
assert!(normalized.apply);
761761
assert_eq!(normalized.deploy_context.as_deref(), Some("minikube"));
762762
assert_eq!(normalized.namespace.as_deref(), Some("default"));
763763
}

src/workflow/render.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ pub fn render_workflow_plan_text(plan: &WorkflowPlan) -> String {
2121
out.push('\n');
2222
}
2323

24+
if plan.profile.approval == crate::workflow::profile::ApprovalMode::External {
25+
out.push_str("Approval:\n");
26+
out.push_str(" mode: external\n");
27+
out.push_str(" provider: GitHub Environment\n");
28+
out.push_str(&format!(" environment: {}\n\n", plan.profile.environment));
29+
}
30+
2431
out.push_str("Overall Effects:\n");
2532
out.push_str(&format!(
2633
" - Mutates Filesystem: {}\n",
@@ -111,6 +118,25 @@ pub fn render_workflow_explain_text(plan: &WorkflowPlan, task_id: &str) -> Resul
111118
out.push_str(&format!("Dependencies: {}\n", task.dependencies.join(", ")));
112119
}
113120

121+
if task.kind == crate::workflow::plan::WorkflowTaskKind::Deploy {
122+
if plan.profile.approval == crate::workflow::profile::ApprovalMode::External {
123+
out.push_str("\nApproval:\n");
124+
out.push_str(" mode: external\n");
125+
out.push_str(" provider: GitHub Environment\n");
126+
out.push_str(&format!(" environment: {}\n", plan.profile.environment));
127+
}
128+
129+
if let Some(ctx) = &plan.profile.deploy_context {
130+
out.push_str("\nContext:\n");
131+
out.push_str(&format!(" {}\n", ctx));
132+
}
133+
134+
if let Some(ns) = &plan.profile.namespace {
135+
out.push_str("\nNamespace:\n");
136+
out.push_str(&format!(" {}\n", ns));
137+
}
138+
}
139+
114140
out.push_str("\nSide Effects:\n");
115141
out.push_str(&format!(
116142
" - Mutates Filesystem: {}\n",

src/workflow/runner.rs

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -173,35 +173,47 @@ fn write_workflow_report(
173173
pub fn validate_workflow_safety(
174174
profile: &crate::workflow::profile::NormalizedWorkflowProfile,
175175
runner: &RunnerContext,
176+
args: &crate::cli::WorkflowRunArgs,
176177
) -> Result<(), String> {
177178
if runner.ci && profile.interactive {
178179
return Err("workflow cannot be interactive in CI".to_string());
179180
}
180181

181-
if runner.ci && profile.apply {
182-
return Err("apply=true is not allowed in CI deploy-plan workflows".to_string());
183-
}
184-
185182
if runner.ci && profile.approval == crate::workflow::profile::ApprovalMode::Prompt {
186183
return Err("approval prompt cannot run in CI".to_string());
187184
}
188185

189-
if profile.deploy.is_active() {
190-
if profile.deploy == crate::workflow::profile::WorkflowStepMode::Run {
191-
match profile.deploy_context.as_deref() {
192-
Some("none") | None => {
193-
return Err("deploy=run requires an explicit real deploy_context".to_string());
194-
}
195-
Some(_) => {}
196-
}
186+
if profile.deploy == crate::workflow::profile::WorkflowStepMode::Run {
187+
let context = profile.deploy_context.as_deref();
188+
189+
if context.is_none() || context == Some("none") {
190+
return Err("deploy=run requires an explicit real deploy_context".to_string());
197191
}
198192

199-
if runner.ci && profile.deploy == crate::workflow::profile::WorkflowStepMode::Run {
200-
return Err("CI deploy workflows are not enabled in this stage".to_string());
193+
if profile.environment == "production" {
194+
return Err("production deploy is not enabled in this stage".to_string());
201195
}
202196

203-
if profile.deploy == crate::workflow::profile::WorkflowStepMode::Run && !profile.apply {
204-
return Err("deploy = run requires apply = true".to_string());
197+
if runner.ci {
198+
if profile.approval != crate::workflow::profile::ApprovalMode::External {
199+
return Err("CI deploy requires approval=external".to_string());
200+
}
201+
202+
if !profile.apply {
203+
return Err("CI deploy requires profile apply=true".to_string());
204+
}
205+
206+
if !args.apply {
207+
return Err("deploy=run in CI requires --apply".to_string());
208+
}
209+
} else {
210+
if !profile.apply {
211+
return Err("deploy=run requires apply=true".to_string());
212+
}
213+
214+
if !runner.interactive && !args.apply {
215+
return Err("non-interactive deploy requires --apply".to_string());
216+
}
205217
}
206218
}
207219

@@ -239,11 +251,13 @@ impl WorkflowRunner {
239251
// 5. Construct BuildOptions (incorporating CLI overrides)
240252
let only = args
241253
.only
242-
.map(|s| crate::builder::split_matches(Some(s)))
254+
.as_ref()
255+
.map(|s| crate::builder::split_matches(Some(s.clone())))
243256
.unwrap_or_default();
244257
let ignore = args
245258
.ignore
246-
.map(|s| crate::builder::split_matches(Some(s)))
259+
.as_ref()
260+
.map(|s| crate::builder::split_matches(Some(s.clone())))
247261
.unwrap_or_default();
248262

249263
let options = BuildOptions {
@@ -260,7 +274,7 @@ impl WorkflowRunner {
260274
};
261275

262276
// 6. Safety validation
263-
validate_workflow_safety(&normalized_profile, &runner_ctx)?;
277+
validate_workflow_safety(&normalized_profile, &runner_ctx, &args)?;
264278

265279
// 7. Plan Pipeline
266280
let planner = WorkflowPlanner::new(
@@ -602,7 +616,7 @@ mod tests {
602616
interactive: true,
603617
};
604618

605-
let res = validate_workflow_safety(&profile, &runner);
619+
let res = validate_workflow_safety(&profile, &runner, &crate::cli::WorkflowRunArgs { profile: "test".to_string(), only: None, ignore: None, non_interactive: true, plan: false, dry_run: false, apply: false });
606620
assert!(res.is_err());
607621
assert!(res
608622
.unwrap_err()
@@ -640,7 +654,7 @@ mod tests {
640654
interactive: false,
641655
};
642656

643-
let res = validate_workflow_safety(&profile, &runner);
657+
let res = validate_workflow_safety(&profile, &runner, &crate::cli::WorkflowRunArgs { profile: "test".to_string(), only: None, ignore: None, non_interactive: true, plan: false, dry_run: false, apply: false });
644658
assert!(res.is_err());
645659
// Since we added a check for apply=true in CI first, it'll hit that instead.
646660
// Let's just check that it fails correctly.
@@ -677,7 +691,7 @@ mod tests {
677691
interactive: false, // user ran with --non-interactive
678692
};
679693

680-
let res = validate_workflow_safety(&profile, &runner);
694+
let res = validate_workflow_safety(&profile, &runner, &crate::cli::WorkflowRunArgs { profile: "test".to_string(), only: None, ignore: None, non_interactive: true, plan: false, dry_run: false, apply: false });
681695
assert!(res.is_err());
682696
assert!(res
683697
.unwrap_err()
@@ -715,10 +729,54 @@ mod tests {
715729
interactive: true,
716730
};
717731

718-
let res = validate_workflow_safety(&profile, &runner);
732+
let res = validate_workflow_safety(&profile, &runner, &crate::cli::WorkflowRunArgs { profile: "test".to_string(), only: None, ignore: None, non_interactive: true, plan: false, dry_run: false, apply: false });
719733
assert!(res.is_err());
720734
assert!(res
721735
.unwrap_err()
722-
.contains("deploy = run requires apply = true"));
736+
.contains("deploy=run requires apply=true"));
737+
}
738+
#[test]
739+
fn validate_safety_ci_staging_deploy_allowed() {
740+
use crate::workflow::profile::{
741+
ApprovalMode, NormalizedWorkflowProfile, ReportMode, WorkflowEngine, WorkflowMode,
742+
WorkflowStepMode,
743+
};
744+
745+
let profile = NormalizedWorkflowProfile {
746+
name: "staging-deploy".to_string(),
747+
environment: "staging".to_string(),
748+
mode: WorkflowMode::Deploy,
749+
engine: WorkflowEngine::Runkernel,
750+
interactive: false,
751+
build: WorkflowStepMode::Plan,
752+
generate: WorkflowStepMode::Run,
753+
deploy: WorkflowStepMode::Run,
754+
test: WorkflowStepMode::Disabled,
755+
verify: WorkflowStepMode::Disabled,
756+
deploy_context: Some("staging".to_string()),
757+
namespace: Some("default".to_string()),
758+
approval: ApprovalMode::External,
759+
apply: true,
760+
report: ReportMode::Both,
761+
};
762+
763+
let runner = RunnerContext {
764+
kind: RunnerKind::GitHubActions,
765+
ci: true,
766+
interactive: false,
767+
};
768+
769+
let args = crate::cli::WorkflowRunArgs {
770+
profile: "staging-deploy".to_string(),
771+
only: None,
772+
ignore: None,
773+
non_interactive: true,
774+
plan: false,
775+
dry_run: false,
776+
apply: true,
777+
};
778+
779+
let res = validate_workflow_safety(&profile, &runner, &args);
780+
assert!(res.is_ok());
723781
}
724782
}

0 commit comments

Comments
 (0)