Skip to main content

yah_qed/
ports.rs

1//! `workflow_call` port contract (R533-F5, W224).
2//!
3//! W224 settles the nesting boundary by *not inventing one*: a reusable GitHub
4//! Actions workflow declares `on: workflow_call: { inputs, outputs, secrets }`,
5//! and that **is** a typed module boundary. An imported workflow is therefore a
6//! **black-box subgraph with ports**, not a flattened step list — internally it
7//! keeps its own jobs/matrix/DAG, but QED sees one node with typed inputs and
8//! outputs, indistinguishable at the boundary from a native step.
9//!
10//! This module extracts that contract from a parsed [`Workflow`]:
11//!
12//! - **down-port (QED → workflow):** the caller's prior content-addressed
13//!   artifacts + env + secrets feed the workflow's `workflow_call` inputs and
14//!   secrets. Secrets land as **env injection** — there is no keystore-of-GitHub
15//!   to reproduce. [`WorkflowPorts::resolve_inputs`] validates required inputs
16//!   and applies declared defaults.
17//! - **up-port (workflow → QED):** the workflow's declared `outputs:` surface as
18//!   **content-addressed QED artifacts** that downstream native steps consume via
19//!   a normal `needs:` edge ([`WorkflowPorts::output_artifacts`]).
20//! - **explicit tier-3 boundary declaration:** the one thing the boundary must
21//!   *additionally* state is which tier-3 facilities the nested box assumes (an
22//!   inner `checkout` / `upload-artifact` needs the native substitute when run on
23//!   QED). [`WorkflowPorts::tier3_assumptions`] is that declaration, computed from
24//!   the R533-F2 classifier scoped to the box — so "runs on GHA today, runs on QED
25//!   tomorrow" stays honest instead of failing mysteriously inside the black box.
26//!
27//! This is W201's `SubPipelineRef::GhaWorkflow` with the port contract made
28//! explicit; the module is pure (no I/O) and operates on the parsed workflow.
29
30use std::collections::HashMap;
31
32use crate::transform::render_exprstring;
33use yah_qed_gha::{classify_workflow, Disposition, NativeReplacement, Workflow};
34
35/// The typed boundary of an imported workflow — its declared ports plus the
36/// tier-3 facilities its body assumes.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WorkflowPorts {
39    /// Whether the workflow declares `on: workflow_call` — i.e. is a *reusable*
40    /// module with an explicit boundary. A top-level workflow imported directly
41    /// has no declared ports (`false`); only its tier-3 assumptions are
42    /// meaningful.
43    pub reusable: bool,
44    /// down-port inputs (`workflow_call.inputs`), in declaration order.
45    pub inputs: Vec<PortInput>,
46    /// down-port secrets (`workflow_call.secrets`) — injected as env at the box.
47    pub secrets: Vec<PortSecret>,
48    /// up-port outputs (`workflow_call.outputs`) — each a content-addressed
49    /// artifact downstream native steps consume via `needs:`.
50    pub outputs: Vec<PortOutput>,
51    /// The distinct tier-3 facilities the nested box assumes, in first-seen
52    /// order — the explicit boundary declaration W224 requires.
53    pub tier3_assumptions: Vec<NativeReplacement>,
54}
55
56/// A `workflow_call` input — a typed down-port the caller must satisfy.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PortInput {
59    pub name: String,
60    pub required: bool,
61    /// Declared `type:` (`string` / `boolean` / `number`), if any.
62    pub ty: Option<String>,
63    /// Rendered default value (expressions preserved), if any.
64    pub default: Option<String>,
65    pub description: Option<String>,
66}
67
68/// A `workflow_call` secret — injected into the box as an environment variable
69/// (the W224 "secrets are ENV-injected" convention; the env var is the secret's
70/// own name, the form `${{ secrets.NAME }}` expands to).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct PortSecret {
73    pub name: String,
74    pub required: bool,
75    /// The environment variable the secret is injected as at the boundary.
76    pub env_var: String,
77    pub description: Option<String>,
78}
79
80/// A `workflow_call` output — an up-port surfaced as a content-addressed QED
81/// artifact downstream native steps reference via `needs:`.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PortOutput {
84    pub name: String,
85    /// The content-addressed artifact id this output surfaces as (the output's
86    /// own name — downstream `needs:` names this).
87    pub artifact: String,
88    /// The rendered value expression backing the output
89    /// (`${{ jobs.build.outputs.digest }}`), if declared.
90    pub value: Option<String>,
91    pub description: Option<String>,
92}
93
94/// Inputs that could not be resolved against the boundary.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum PortError {
97    /// Required inputs with no supplied value and no default.
98    MissingRequired(Vec<String>),
99}
100
101impl WorkflowPorts {
102    /// Resolve the down-port: validate that every required input is supplied (or
103    /// has a default), and return the effective input map (supplied wins over
104    /// default). Unknown supplied keys are ignored — extra context is harmless.
105    pub fn resolve_inputs(
106        &self,
107        supplied: &HashMap<String, String>,
108    ) -> Result<HashMap<String, String>, PortError> {
109        let mut resolved = HashMap::new();
110        let mut missing = Vec::new();
111        for input in &self.inputs {
112            if let Some(v) = supplied.get(&input.name) {
113                resolved.insert(input.name.clone(), v.clone());
114            } else if let Some(d) = &input.default {
115                resolved.insert(input.name.clone(), d.clone());
116            } else if input.required {
117                missing.push(input.name.clone());
118            }
119        }
120        if missing.is_empty() {
121            Ok(resolved)
122        } else {
123            Err(PortError::MissingRequired(missing))
124        }
125    }
126
127    /// The up-port: outputs as content-addressed artifacts downstream consumes.
128    pub fn output_artifacts(&self) -> impl Iterator<Item = &PortOutput> {
129        self.outputs.iter()
130    }
131
132    /// The env-injection map for the down-port secrets (`env_var → secret name`).
133    pub fn secret_env(&self) -> HashMap<String, String> {
134        self.secrets.iter().map(|s| (s.env_var.clone(), s.name.clone())).collect()
135    }
136
137    /// Whether the box assumes a given tier-3 facility — the boundary needs its
138    /// native substitute before the import can run on QED.
139    pub fn assumes(&self, facility: NativeReplacement) -> bool {
140        self.tier3_assumptions.contains(&facility)
141    }
142}
143
144/// Extract the `workflow_call` port contract from a parsed workflow.
145///
146/// Always returns a [`WorkflowPorts`]: when the workflow declares no
147/// `on: workflow_call`, the declared-port lists are empty (`reusable = false`)
148/// but [`tier3_assumptions`](WorkflowPorts::tier3_assumptions) is still computed
149/// from the body, since every imported workflow has a tier-3 boundary.
150pub fn workflow_ports(wf: &Workflow) -> WorkflowPorts {
151    let call = wf.triggers.workflow_call.as_ref();
152    let reusable = call.is_some();
153
154    let inputs = call
155        .map(|c| {
156            c.inputs
157                .iter()
158                .map(|(name, i)| PortInput {
159                    name: name.clone(),
160                    required: i.required.unwrap_or(false),
161                    ty: i.r#type.clone(),
162                    default: i.default.as_ref().map(render_exprstring),
163                    description: i.description.clone(),
164                })
165                .collect()
166        })
167        .unwrap_or_default();
168
169    let secrets = call
170        .map(|c| {
171            c.secrets
172                .iter()
173                .map(|(name, s)| PortSecret {
174                    name: name.clone(),
175                    required: s.required.unwrap_or(false),
176                    env_var: name.clone(),
177                    description: s.description.clone(),
178                })
179                .collect()
180        })
181        .unwrap_or_default();
182
183    let outputs = call
184        .map(|c| {
185            c.outputs
186                .iter()
187                .map(|(name, o)| PortOutput {
188                    name: name.clone(),
189                    artifact: name.clone(),
190                    value: o.value.as_ref().map(render_exprstring),
191                    description: o.description.clone(),
192                })
193                .collect()
194        })
195        .unwrap_or_default();
196
197    WorkflowPorts { reusable, inputs, secrets, outputs, tier3_assumptions: tier3_assumptions(wf) }
198}
199
200/// The distinct tier-3 facilities a workflow's steps assume, in first-seen
201/// order — the explicit boundary declaration. Computed from the R533-F2
202/// classifier over every step in the box.
203fn tier3_assumptions(wf: &Workflow) -> Vec<NativeReplacement> {
204    let mut out: Vec<NativeReplacement> = Vec::new();
205    for classified in classify_workflow(wf) {
206        if let Disposition::ReplaceWithNative(nr) = classified.class.disposition {
207            if !out.contains(&nr) {
208                out.push(nr);
209            }
210        }
211    }
212    out
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn wf(src: &str) -> Workflow {
220        yah_qed_gha::parse_workflow(src).expect("parse")
221    }
222
223    const REUSABLE: &str = r#"
224name: build-and-publish
225on:
226  workflow_call:
227    inputs:
228      tag:
229        required: true
230        type: string
231      channel:
232        required: false
233        type: string
234        default: stable
235    secrets:
236      CARGO_TOKEN:
237        required: true
238    outputs:
239      digest:
240        description: the built image digest
241        value: ${{ jobs.build.outputs.digest }}
242jobs:
243  build:
244    runs-on: ubuntu-latest
245    steps:
246      - uses: actions/checkout@v4
247      - uses: actions/upload-artifact@v4
248      - run: cargo build --release
249"#;
250
251    #[test]
252    fn reusable_workflow_declares_typed_ports() {
253        let p = workflow_ports(&wf(REUSABLE));
254        assert!(p.reusable);
255
256        // down-port inputs
257        assert_eq!(p.inputs.len(), 2);
258        let tag = &p.inputs[0];
259        assert_eq!(tag.name, "tag");
260        assert!(tag.required);
261        assert_eq!(tag.ty.as_deref(), Some("string"));
262        let channel = &p.inputs[1];
263        assert!(!channel.required);
264        assert_eq!(channel.default.as_deref(), Some("stable"));
265
266        // down-port secrets → env injection
267        assert_eq!(p.secrets.len(), 1);
268        assert_eq!(p.secrets[0].name, "CARGO_TOKEN");
269        assert_eq!(p.secrets[0].env_var, "CARGO_TOKEN");
270        assert!(p.secrets[0].required);
271
272        // up-port outputs → content-addressed artifacts
273        assert_eq!(p.outputs.len(), 1);
274        assert_eq!(p.outputs[0].name, "digest");
275        assert_eq!(p.outputs[0].artifact, "digest");
276        assert_eq!(p.outputs[0].value.as_deref(), Some("${{ jobs.build.outputs.digest }}"));
277    }
278
279    #[test]
280    fn down_port_resolves_inputs_with_defaults_and_required() {
281        let p = workflow_ports(&wf(REUSABLE));
282
283        // Missing the required `tag` → error naming it.
284        let err = p.resolve_inputs(&HashMap::new()).unwrap_err();
285        assert_eq!(err, PortError::MissingRequired(vec!["tag".into()]));
286
287        // Supplying `tag` → `channel` falls back to its default.
288        let supplied = HashMap::from([("tag".to_string(), "v1.2.3".to_string())]);
289        let resolved = p.resolve_inputs(&supplied).expect("resolves");
290        assert_eq!(resolved.get("tag").map(String::as_str), Some("v1.2.3"));
291        assert_eq!(resolved.get("channel").map(String::as_str), Some("stable"));
292    }
293
294    #[test]
295    fn secret_env_injection_map() {
296        let p = workflow_ports(&wf(REUSABLE));
297        let env = p.secret_env();
298        assert_eq!(env.get("CARGO_TOKEN").map(String::as_str), Some("CARGO_TOKEN"));
299    }
300
301    #[test]
302    fn explicit_tier3_boundary_declaration() {
303        let p = workflow_ports(&wf(REUSABLE));
304        // checkout + upload-artifact are the tier-3 facilities the box assumes.
305        assert!(p.assumes(NativeReplacement::Checkout));
306        assert!(p.assumes(NativeReplacement::UploadArtifact));
307        assert!(!p.assumes(NativeReplacement::ReleasePublisher));
308        // Distinct + first-seen order, no duplicates.
309        assert_eq!(
310            p.tier3_assumptions,
311            vec![NativeReplacement::Checkout, NativeReplacement::UploadArtifact]
312        );
313    }
314
315    #[test]
316    fn non_reusable_workflow_has_no_ports_but_keeps_tier3_declaration() {
317        let src = r#"
318on: push
319jobs:
320  a:
321    runs-on: x
322    steps:
323      - uses: actions/checkout@v4
324      - run: make
325"#;
326        let p = workflow_ports(&wf(src));
327        assert!(!p.reusable);
328        assert!(p.inputs.is_empty());
329        assert!(p.secrets.is_empty());
330        assert!(p.outputs.is_empty());
331        // A top-level import still declares its tier-3 boundary.
332        assert_eq!(p.tier3_assumptions, vec![NativeReplacement::Checkout]);
333    }
334
335    #[test]
336    fn tier3_assumptions_dedupe_across_jobs() {
337        // checkout in two jobs → declared once.
338        let src = r#"
339on: workflow_call
340jobs:
341  a:
342    runs-on: x
343    steps:
344      - uses: actions/checkout@v4
345  b:
346    runs-on: x
347    steps:
348      - uses: actions/checkout@v4
349      - uses: actions/cache@v4
350"#;
351        let p = workflow_ports(&wf(src));
352        assert_eq!(
353            p.tier3_assumptions,
354            vec![NativeReplacement::Checkout, NativeReplacement::ContentAddressedCache]
355        );
356    }
357
358    #[test]
359    fn output_artifacts_iterator_exposes_up_port() {
360        let p = workflow_ports(&wf(REUSABLE));
361        let arts: Vec<&str> = p.output_artifacts().map(|o| o.artifact.as_str()).collect();
362        assert_eq!(arts, vec!["digest"]);
363    }
364}