mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
fix(executor): propagate composite action outputs back to caller
It turns out that execute_composite_action() was happily running all
the internal steps of a composite action, correctly tracking their
outputs in composite_step_outputs, and then... just throwing all of
that away. The action.yml `outputs:` section — the whole reason
composite actions *have* a return path — was never read or evaluated.
So ${{ steps.my-composite.outputs.whatever }} always resolved to
empty string. Inputs worked fine. The internal steps ran fine. The
output values were right there in memory. Nobody bothered to connect
the last wire.
Add propagate_composite_outputs() which reads the action's outputs
section after the step loop, evaluates each value expression against
the composite's internal step context, and writes the results to the
caller's GITHUB_OUTPUT file. The existing apply_step_environment_updates
pipeline then picks them up naturally — no changes to StepResult or
process_outcome needed.
Also wire this into the early-return failure path so partial outputs
are still available when a composite step fails.
This commit is contained in:
@@ -4638,6 +4638,14 @@ async fn execute_composite_action(
|
||||
|
||||
// Short-circuit on failure if needed
|
||||
if step_result.status == StepStatus::Failure {
|
||||
// Still propagate whatever outputs were collected before the failure
|
||||
propagate_composite_outputs(
|
||||
&action_def,
|
||||
&composite_step_outputs,
|
||||
&action_env,
|
||||
job_env,
|
||||
working_dir,
|
||||
);
|
||||
return Ok(StepResult::new(
|
||||
step.name
|
||||
.clone()
|
||||
@@ -4648,6 +4656,15 @@ async fn execute_composite_action(
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate composite action outputs to the caller's GITHUB_OUTPUT
|
||||
propagate_composite_outputs(
|
||||
&action_def,
|
||||
&composite_step_outputs,
|
||||
&action_env,
|
||||
job_env,
|
||||
working_dir,
|
||||
);
|
||||
|
||||
// All steps completed successfully
|
||||
let output = if verbose {
|
||||
let mut detailed_output = format!(
|
||||
@@ -4700,6 +4717,78 @@ async fn execute_composite_action(
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a composite action's `outputs:` section and write the resolved values
|
||||
/// to the caller's GITHUB_OUTPUT file so `${{ steps.<id>.outputs.<key> }}` works.
|
||||
fn propagate_composite_outputs(
|
||||
action_def: &serde_yaml::Value,
|
||||
composite_step_outputs: &HashMap<String, HashMap<String, String>>,
|
||||
action_env: &HashMap<String, String>,
|
||||
caller_job_env: &HashMap<String, String>,
|
||||
working_dir: &Path,
|
||||
) {
|
||||
let outputs = match action_def.get("outputs").and_then(|v| v.as_mapping()) {
|
||||
Some(m) => m,
|
||||
None => return, // No outputs declared
|
||||
};
|
||||
|
||||
// Build an expression context scoped to the composite's internal steps
|
||||
let empty_matrix = None;
|
||||
let empty_statuses = HashMap::new();
|
||||
let empty_secrets = HashMap::new();
|
||||
let empty_needs = HashMap::new();
|
||||
let empty_results = HashMap::new();
|
||||
let expr_ctx = crate::expression::ExpressionContext {
|
||||
env_context: action_env,
|
||||
step_outputs: composite_step_outputs,
|
||||
matrix_combination: &empty_matrix,
|
||||
step_statuses: &empty_statuses,
|
||||
job_status: "success",
|
||||
secrets_context: &empty_secrets,
|
||||
needs_context: &empty_needs,
|
||||
needs_results: &empty_results,
|
||||
};
|
||||
|
||||
// Collect evaluated outputs
|
||||
let mut resolved: Vec<(String, String)> = Vec::new();
|
||||
for (key, def) in outputs {
|
||||
let key_str = match key.as_str() {
|
||||
Some(k) => k,
|
||||
None => continue,
|
||||
};
|
||||
let value_expr = match def.get("value").and_then(|v| v.as_str()) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
match crate::substitution::preprocess_expressions(value_expr, working_dir, &expr_ctx) {
|
||||
Ok(val) => resolved.push((key_str.to_string(), val)),
|
||||
Err(e) => {
|
||||
wrkflw_logging::debug(&format!(
|
||||
"Failed to evaluate composite output '{}': {}",
|
||||
key_str, e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Append to the caller's GITHUB_OUTPUT file
|
||||
if let Some(output_path) = caller_job_env.get("GITHUB_OUTPUT") {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(output_path)
|
||||
{
|
||||
for (key, value) in &resolved {
|
||||
let _ = writeln!(f, "{}={}", key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert YAML step to our Step struct
|
||||
fn convert_yaml_to_step(step_yaml: &serde_yaml::Value) -> Result<workflow::Step, String> {
|
||||
// Extract step properties
|
||||
@@ -8463,4 +8552,87 @@ runs:
|
||||
process_workflow_commands("::set-output name=x::val\n", None, &mut outputs, None);
|
||||
assert!(outputs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_composite_outputs_writes_to_github_output_file() {
|
||||
// Simulate a composite action with an outputs section that references
|
||||
// an internal step output via ${{ steps.build-msg.outputs.msg }}
|
||||
let action_yaml = r#"
|
||||
name: Greet
|
||||
outputs:
|
||||
message:
|
||||
description: The greeting
|
||||
value: ${{ steps.build-msg.outputs.msg }}
|
||||
static_val:
|
||||
description: A literal
|
||||
value: hello-literal
|
||||
runs:
|
||||
using: composite
|
||||
steps: []
|
||||
"#;
|
||||
let action_def: serde_yaml::Value = serde_yaml::from_str(action_yaml).unwrap();
|
||||
|
||||
// Populate the composite's internal step outputs
|
||||
let mut composite_step_outputs: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
let mut build_msg_outputs = HashMap::new();
|
||||
build_msg_outputs.insert("msg".to_string(), "Hi, World!".to_string());
|
||||
composite_step_outputs.insert("build-msg".to_string(), build_msg_outputs);
|
||||
|
||||
let action_env = HashMap::new();
|
||||
|
||||
// Create a temp file to act as the caller's GITHUB_OUTPUT
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let tmp_path = tmp.path().to_string_lossy().to_string();
|
||||
let mut caller_env = HashMap::new();
|
||||
caller_env.insert("GITHUB_OUTPUT".to_string(), tmp_path.clone());
|
||||
|
||||
let working_dir = std::env::temp_dir();
|
||||
|
||||
propagate_composite_outputs(
|
||||
&action_def,
|
||||
&composite_step_outputs,
|
||||
&action_env,
|
||||
&caller_env,
|
||||
&working_dir,
|
||||
);
|
||||
|
||||
// Read the GITHUB_OUTPUT file — it should contain the evaluated outputs
|
||||
let content = std::fs::read_to_string(&tmp_path).unwrap();
|
||||
assert!(
|
||||
content.contains("message=Hi, World!"),
|
||||
"Expected 'message=Hi, World!' in GITHUB_OUTPUT, got: {:?}",
|
||||
content
|
||||
);
|
||||
assert!(
|
||||
content.contains("static_val=hello-literal"),
|
||||
"Expected 'static_val=hello-literal' in GITHUB_OUTPUT, got: {:?}",
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_composite_outputs_no_outputs_section_is_noop() {
|
||||
let action_yaml = r#"
|
||||
name: NoOutputs
|
||||
runs:
|
||||
using: composite
|
||||
steps: []
|
||||
"#;
|
||||
let action_def: serde_yaml::Value = serde_yaml::from_str(action_yaml).unwrap();
|
||||
let composite_step_outputs = HashMap::new();
|
||||
let action_env = HashMap::new();
|
||||
|
||||
// No GITHUB_OUTPUT in env — should not panic
|
||||
let caller_env = HashMap::new();
|
||||
let working_dir = std::env::temp_dir();
|
||||
|
||||
propagate_composite_outputs(
|
||||
&action_def,
|
||||
&composite_step_outputs,
|
||||
&action_env,
|
||||
&caller_env,
|
||||
&working_dir,
|
||||
);
|
||||
// No assertion needed — just verifying it doesn't panic
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user