Skip to content

Commit 6377163

Browse files
pepicrftclaude
andcommitted
feat: add container execution for GitHub Actions and Forgejo Actions
Extend container-based execution to all CI systems: - Execute `run:` steps in containers for GitHub Actions and Forgejo - Map runner names (ubuntu-latest, etc.) to Docker images - Support explicit container specification in Forgejo - Skip `uses:` steps with clear messaging (support coming soon) - Remove all act references - Magnolia replaces act, doesn't complement it All three CI systems now support: - Container execution (Podman/Docker) when available - Graceful fallback to host execution - Confirmation prompts before running - Clear execution status and error reporting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6953263 commit 6377163

5 files changed

Lines changed: 244 additions & 53 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ magnolia .forgejo/workflows/deploy.yml
4949

5050
### ⚡ Execution
5151

52-
- **GitLab CI**: Scripts are executed directly on your machine after confirmation. You'll see each command before it runs.
53-
- **GitHub Actions / Forgejo Actions**: Displays job details and recommends using [act](https://github.com/nektos/act) for local execution with Docker containers.
52+
- **GitLab CI**: Executes jobs in containers (Podman/Docker) when `image:` is specified, or on host otherwise.
53+
- **GitHub Actions / Forgejo Actions**: Executes `run:` steps in containers based on `runs-on:` runner. Marketplace actions (`uses:`) support coming soon.
5454

5555
## 🔧 Supported Systems
5656

src/container.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,63 @@ pub async fn execute_on_host(command: &str) -> Result<()> {
110110

111111
Ok(())
112112
}
113+
114+
/// Map GitHub Actions / Forgejo Actions runner names to Docker images
115+
pub fn map_runner_to_image(runs_on: &str) -> Option<&'static str> {
116+
match runs_on {
117+
// Ubuntu runners
118+
"ubuntu-latest" | "ubuntu-24.04" => Some("ubuntu:24.04"),
119+
"ubuntu-22.04" => Some("ubuntu:22.04"),
120+
"ubuntu-20.04" => Some("ubuntu:20.04"),
121+
122+
// Debian runners
123+
"debian-latest" | "debian-12" => Some("debian:12"),
124+
"debian-11" => Some("debian:11"),
125+
126+
// Alpine runners
127+
"alpine-latest" | "alpine-3" => Some("alpine:latest"),
128+
129+
// macOS and Windows runners cannot run in containers on Linux
130+
s if s.starts_with("macos") => None,
131+
s if s.starts_with("windows") => None,
132+
133+
// Unknown runner
134+
_ => None,
135+
}
136+
}
137+
138+
/// Execute multiple commands (steps) in a container or on host
139+
pub async fn execute_steps(
140+
runtime: Option<&ContainerRuntime>,
141+
image: Option<&str>,
142+
commands: &[String],
143+
) -> Result<()> {
144+
if let (Some(rt), Some(img)) = (runtime, image) {
145+
// Container execution - combine commands
146+
let combined_script = commands.join(" && ");
147+
148+
println!("\n{} Executing in container", "→".cyan());
149+
150+
execute_in_container(rt, img, &combined_script, "/workspace").await?;
151+
} else {
152+
// Host execution
153+
if image.is_some() && runtime.is_none() {
154+
println!(
155+
"\n{} No container runtime found (Podman/Docker), falling back to host execution",
156+
"⚠".yellow()
157+
);
158+
}
159+
160+
println!("\n{} Executing on host", "→".cyan());
161+
162+
for (i, cmd) in commands.iter().enumerate() {
163+
println!("\n{} {}", format!("[{}/{}]", i + 1, commands.len()).cyan(), cmd.yellow());
164+
165+
execute_on_host(cmd).await.context(format!("Command failed: {}", cmd))?;
166+
167+
println!("{}", "✓ Command succeeded".green());
168+
}
169+
}
170+
171+
Ok(())
172+
}

src/forgejo.rs

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
44
use std::collections::HashMap;
55
use std::path::PathBuf;
66

7+
use crate::container;
8+
79
#[derive(Debug, Deserialize, Serialize)]
810
struct ForgejoWorkflow {
911
name: Option<String>,
@@ -108,43 +110,109 @@ pub async fn run_job_from_file(pipeline_path: &PathBuf, job_name: &str) -> Resul
108110
.context(format!("Failed to parse {}", pipeline_path.display()))?;
109111

110112
if let Some(job) = workflow.jobs.get(job_name) {
111-
println!("\n{}", format!("▶ Analyzing job: {}", job_name).cyan().bold());
112-
println!(
113-
" Runs on: {}",
114-
job.runs_on.as_deref().unwrap_or("N/A").blue()
115-
);
113+
println!("\n{}", format!("▶ Executing job: {}", job_name).green().bold());
114+
115+
let runs_on = job.runs_on.as_deref().unwrap_or("ubuntu-latest");
116+
println!(" Runs on: {}", runs_on.blue());
116117

117118
if let Some(container) = &job.container {
118119
println!(" Container: {}", container.dimmed());
119120
}
120121

121122
if let Some(steps) = &job.steps {
123+
// Extract run commands and check for uses steps
124+
let mut run_commands = Vec::new();
125+
let mut has_uses_steps = false;
126+
let mut skipped_steps = Vec::new();
127+
122128
println!("\n{}", "Steps:".cyan());
123129
for (i, step) in steps.iter().enumerate() {
124-
if let Some(name) = &step.name {
125-
println!(" {}. {}", i + 1, name);
130+
if let Some(run) = &step.run {
131+
let step_name = step.name.as_deref().unwrap_or("(unnamed)");
132+
println!(" {}. {} {}", i + 1, "Run:".green(), step_name);
133+
run_commands.push(run.clone());
126134
} else if let Some(uses) = &step.uses {
127-
println!(" {}. Uses: {}", i + 1, uses.dimmed());
128-
} else if let Some(run) = &step.run {
129-
println!(
130-
" {}. Run: {}",
131-
i + 1,
132-
run.lines().next().unwrap_or("").dimmed()
133-
);
135+
has_uses_steps = true;
136+
skipped_steps.push(uses.clone());
137+
println!(" {}. {} {} {}", i + 1, "Uses:".yellow(), uses.dimmed(), "(skipped)".dimmed());
134138
}
135139
}
136-
}
137140

138-
println!("\n{}", "─".repeat(60).dimmed());
139-
println!("\n{}", "ℹ Forgejo Actions Execution".yellow().bold());
140-
println!("Forgejo Actions are compatible with GitHub Actions and use marketplace actions.");
141-
println!("\nFor local execution, consider using:");
142-
println!(" • {} - Run GitHub Actions locally using Docker", "act".cyan());
143-
println!(" Install: brew install act");
144-
println!(" Usage: act -j {}", job_name);
145-
println!("\n{}", "─".repeat(60).dimmed());
141+
if run_commands.is_empty() {
142+
if has_uses_steps {
143+
println!("\n{}", "─".repeat(60).dimmed());
144+
println!("\n{}", "⚠ Forgejo Actions marketplace actions not yet supported".yellow().bold());
145+
println!("This workflow only contains 'uses' steps (marketplace actions).");
146+
println!("Support for marketplace actions is coming soon!");
147+
println!("\n{}", "─".repeat(60).dimmed());
148+
} else {
149+
println!("\n{}", "No executable steps found in this job".yellow());
150+
}
151+
return Ok(());
152+
}
153+
154+
// Show commands to execute
155+
println!("\n{}", "Commands to execute:".cyan());
156+
for cmd in &run_commands {
157+
let first_line = cmd.lines().next().unwrap_or("");
158+
println!(" $ {}", first_line.dimmed());
159+
}
146160

147-
return Ok(());
161+
// Ask for confirmation
162+
let confirm = inquire::Confirm::new("Execute these commands?")
163+
.with_default(false)
164+
.prompt()
165+
.unwrap_or(false);
166+
167+
if !confirm {
168+
println!("{}", "Execution cancelled".yellow());
169+
return Ok(());
170+
}
171+
172+
println!("\n{}", "─".repeat(60).dimmed());
173+
174+
// Detect container runtime
175+
let runtime = container::detect_runtime().await;
176+
177+
// Determine which image to use
178+
// Priority: explicit container > mapped runner > None
179+
let image = if let Some(container_img) = &job.container {
180+
println!(" Using explicit container: {}", container_img.yellow());
181+
Some(container_img.as_str())
182+
} else if let Some(mapped_img) = container::map_runner_to_image(runs_on) {
183+
println!(" Mapped {} → {}", runs_on.blue(), mapped_img.yellow());
184+
Some(mapped_img)
185+
} else if runs_on.starts_with("macos") || runs_on.starts_with("windows") {
186+
println!(
187+
"\n{} {} runners not supported in containers, using host execution",
188+
"⚠".yellow(),
189+
runs_on
190+
);
191+
None
192+
} else {
193+
None
194+
};
195+
196+
// Execute steps
197+
container::execute_steps(runtime.as_ref(), image, &run_commands).await?;
198+
199+
println!("\n{}", "─".repeat(60).dimmed());
200+
println!("\n{}", format!("✓ Job '{}' completed successfully", job_name).green().bold());
201+
202+
// Show note about skipped uses steps
203+
if has_uses_steps {
204+
println!("\n{}", "Note:".yellow().bold());
205+
println!("The following 'uses' steps were skipped (not yet supported):");
206+
for uses in skipped_steps {
207+
println!(" - {}", uses.dimmed());
208+
}
209+
}
210+
211+
return Ok(());
212+
} else {
213+
println!("\n{}", "No steps defined in this job".yellow());
214+
return Ok(());
215+
}
148216
}
149217

150218
anyhow::bail!("Job '{}' not found", job_name)

src/github.rs

Lines changed: 89 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
44
use std::collections::HashMap;
55
use std::path::PathBuf;
66

7+
use crate::container;
8+
79
#[derive(Debug, Deserialize, Serialize)]
810
struct GitHubWorkflow {
911
name: Option<String>,
@@ -96,40 +98,101 @@ pub async fn run_job_from_file(pipeline_path: &PathBuf, job_name: &str) -> Resul
9698
.context(format!("Failed to parse {}", pipeline_path.display()))?;
9799

98100
if let Some(job) = workflow.jobs.get(job_name) {
99-
println!("\n{}", format!("▶ Analyzing job: {}", job_name).cyan().bold());
100-
println!(
101-
" Runs on: {}",
102-
job.runs_on.as_deref().unwrap_or("N/A").blue()
103-
);
101+
println!("\n{}", format!("▶ Executing job: {}", job_name).green().bold());
102+
103+
let runs_on = job.runs_on.as_deref().unwrap_or("ubuntu-latest");
104+
println!(" Runs on: {}", runs_on.blue());
104105

105106
if let Some(steps) = &job.steps {
107+
// Extract run commands and check for uses steps
108+
let mut run_commands = Vec::new();
109+
let mut has_uses_steps = false;
110+
let mut skipped_steps = Vec::new();
111+
106112
println!("\n{}", "Steps:".cyan());
107113
for (i, step) in steps.iter().enumerate() {
108-
if let Some(name) = &step.name {
109-
println!(" {}. {}", i + 1, name);
114+
if let Some(run) = &step.run {
115+
let step_name = step.name.as_deref().unwrap_or("(unnamed)");
116+
println!(" {}. {} {}", i + 1, "Run:".green(), step_name);
117+
run_commands.push(run.clone());
110118
} else if let Some(uses) = &step.uses {
111-
println!(" {}. Uses: {}", i + 1, uses.dimmed());
112-
} else if let Some(run) = &step.run {
113-
println!(
114-
" {}. Run: {}",
115-
i + 1,
116-
run.lines().next().unwrap_or("").dimmed()
117-
);
119+
has_uses_steps = true;
120+
skipped_steps.push(uses.clone());
121+
println!(" {}. {} {} {}", i + 1, "Uses:".yellow(), uses.dimmed(), "(skipped)".dimmed());
118122
}
119123
}
120-
}
121124

122-
println!("\n{}", "─".repeat(60).dimmed());
123-
println!("\n{}", "ℹ GitHub Actions Execution".yellow().bold());
124-
println!("GitHub Actions workflows use marketplace actions (e.g., actions/checkout@v4)");
125-
println!("and require a GitHub Actions-compatible runtime.");
126-
println!("\nFor local execution, consider using:");
127-
println!(" • {} - Run GitHub Actions locally using Docker", "act".cyan());
128-
println!(" Install: brew install act");
129-
println!(" Usage: act -j {}", job_name);
130-
println!("\n{}", "─".repeat(60).dimmed());
131-
132-
return Ok(());
125+
if run_commands.is_empty() {
126+
if has_uses_steps {
127+
println!("\n{}", "─".repeat(60).dimmed());
128+
println!("\n{}", "⚠ GitHub Actions marketplace actions not yet supported".yellow().bold());
129+
println!("This workflow only contains 'uses' steps (marketplace actions like actions/checkout).");
130+
println!("Support for marketplace actions is coming soon!");
131+
println!("\n{}", "─".repeat(60).dimmed());
132+
} else {
133+
println!("\n{}", "No executable steps found in this job".yellow());
134+
}
135+
return Ok(());
136+
}
137+
138+
// Show commands to execute
139+
println!("\n{}", "Commands to execute:".cyan());
140+
for cmd in &run_commands {
141+
let first_line = cmd.lines().next().unwrap_or("");
142+
println!(" $ {}", first_line.dimmed());
143+
}
144+
145+
// Ask for confirmation
146+
let confirm = inquire::Confirm::new("Execute these commands?")
147+
.with_default(false)
148+
.prompt()
149+
.unwrap_or(false);
150+
151+
if !confirm {
152+
println!("{}", "Execution cancelled".yellow());
153+
return Ok(());
154+
}
155+
156+
println!("\n{}", "─".repeat(60).dimmed());
157+
158+
// Detect container runtime
159+
let runtime = container::detect_runtime().await;
160+
161+
// Map runner to image
162+
let image = container::map_runner_to_image(runs_on);
163+
164+
if image.is_some() {
165+
if let Some(img) = image {
166+
println!(" Mapped {} → {}", runs_on.blue(), img.yellow());
167+
}
168+
} else if runs_on.starts_with("macos") || runs_on.starts_with("windows") {
169+
println!(
170+
"\n{} {} runners not supported in containers, using host execution",
171+
"⚠".yellow(),
172+
runs_on
173+
);
174+
}
175+
176+
// Execute steps
177+
container::execute_steps(runtime.as_ref(), image, &run_commands).await?;
178+
179+
println!("\n{}", "─".repeat(60).dimmed());
180+
println!("\n{}", format!("✓ Job '{}' completed successfully", job_name).green().bold());
181+
182+
// Show note about skipped uses steps
183+
if has_uses_steps {
184+
println!("\n{}", "Note:".yellow().bold());
185+
println!("The following 'uses' steps were skipped (not yet supported):");
186+
for uses in skipped_steps {
187+
println!(" - {}", uses.dimmed());
188+
}
189+
}
190+
191+
return Ok(());
192+
} else {
193+
println!("\n{}", "No steps defined in this job".yellow());
194+
return Ok(());
195+
}
133196
}
134197

135198
anyhow::bail!("Job '{}' not found", job_name)

0 commit comments

Comments
 (0)