yah-qed 0.8.46

CI scheduler: pipelines, step DAGs, triggers, and pass/fail gating over task execution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
//! Eject / materialize an imported workflow to hash-stamped TOML (R533-F6, W224).
//!
//! W224's import primitive has one toggle with two states, and that toggle *is*
//! the migration ramp:
//!
//! - **Virtual** (default, R533-F1): expand the `workflow.yml` into the in-memory
//!   subgraph at plan time, persist nothing. Zero drift by construction — there
//!   is no stored derivative to diverge.
//! - **Eject / materialize** (this module): write the F4 transform's native
//!   steps as a generated, **hash-stamped** TOML pipeline. A one-time directional
//!   move — after ejecting, the TOML is canonical and hand-editable and the
//!   source yml can be deleted. The "sync button" is an *eject* button.
//!
//! The hard rule W224 sets is **never two editable canonical copies at once**.
//! While the yml is canonical the TOML is virtual; once ejected the yml is gone.
//! If a materialized TOML must coexist with its yml during an overlap window, the
//! pinned source hash is the guardrail:
//!
//! - [`freshness`] recomputes the source hash on demand; a mismatch means the
//!   source drifted since the eject ([`EjectFreshness::StaleSource`]).
//! - [`validate_ejected`] **re-expands** the source and compares it byte-for-byte
//!   against the on-disk generated body, so a hand-edit of a generated file is
//!   caught and never silently honored — and a drifted source is reported
//!   distinctly from a hand-edit.
//!
//! ## Provenance lives in a comment header, not the pipeline body
//!
//! The generated body is a **100%-valid normal [`Pipeline`] TOML** — the existing
//! loader runs an ejected pipeline with no special-casing. Provenance (source
//! path + pinned hash) and the F4 flags ride in a leading `# @qed:generated …`
//! comment header that the loader ignores and this module parses. That keeps the
//! eject reversible-by-inspection and avoids both a 46-site `Pipeline` field add
//! and TOML's table-after-array ordering trap.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use crate::import::content_hash;
use crate::transform::{transform_workflow, FlagKind, TransformReport};
use crate::types::{Environment, Pipeline};
use yah_qed_gha::Workflow;

/// Marker beginning the provenance comment line. The whole header is a run of
/// leading `#` comments; only the `@qed:generated` line carries the pin.
const HEADER_TAG: &str = "# @qed:generated";

/// Parsed provenance of an ejected pipeline — what it was generated from and the
/// source hash pinned at eject time.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedHeader {
    /// The `workflow.yml` this TOML was ejected from (camp-relative).
    pub source: PathBuf,
    /// blake3 [`content_hash`] of the source bytes at eject time — the pin the
    /// freshness / validate guards compare against.
    pub source_hash: String,
}

/// Freshness of an on-disk ejected pipeline relative to its source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EjectFreshness {
    /// The source's current hash matches the pin — the eject is up to date.
    Fresh,
    /// The source drifted since the eject. Under materialization this marks the
    /// eject "dirty" (re-eject needed); carries both hashes for reporting.
    StaleSource { pinned: String, actual: String },
}

impl EjectFreshness {
    pub fn is_fresh(&self) -> bool {
        matches!(self, EjectFreshness::Fresh)
    }
}

/// Why an on-disk ejected pipeline failed [`validate_ejected`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidateError {
    /// No `# @qed:generated` header — the file isn't an ejected pipeline (or the
    /// header was stripped), so there's nothing to re-expand against.
    NotGenerated,
    /// The source drifted since the eject (pin mismatch). Re-eject to refresh.
    SourceDrifted { pinned: String, actual: String },
    /// The on-disk generated body no longer matches what re-expanding the source
    /// produces — a hand-edit of a generated file. W224: caught, never honored.
    HandEdited,
}

/// Eject an imported workflow to a hash-stamped, generated TOML string.
///
/// `source` is the camp-relative path recorded in the header; `source_bytes` are
/// the exact bytes hashed for the pin (the caller owns the file read — this stays
/// pure). The body is the F4 [`transform`](crate::transform) of `workflow`
/// rendered as a [`Pipeline`]; tier-3 / unknown flags are surfaced as header
/// comments so the human sees what still needs a native replacement.
pub fn eject(source: &Path, source_bytes: &[u8], workflow: &Workflow) -> String {
    let report = transform_workflow(workflow);
    let header = GeneratedHeader { source: source.to_path_buf(), source_hash: content_hash(source_bytes) };
    render_document(&header, &report)
}

/// Read an ejected pipeline's provenance header, if it has one.
///
/// `None` means the file is hand-authored, not generated — the caller should
/// leave it alone. This is how a validator tells "a pipeline I must re-expand
/// and check" from "a pipeline someone wrote", without loading either.
pub fn generated_header(generated_toml: &str) -> Option<GeneratedHeader> {
    parse_header(generated_toml)
}

/// Recompute the source hash and compare against an ejected pipeline's pin.
/// `current_source_bytes` are the bytes on disk now; returns [`EjectFreshness`].
/// `None` when `generated_toml` carries no `# @qed:generated` header.
pub fn freshness(generated_toml: &str, current_source_bytes: &[u8]) -> Option<EjectFreshness> {
    let header = parse_header(generated_toml)?;
    let actual = content_hash(current_source_bytes);
    Some(if actual == header.source_hash {
        EjectFreshness::Fresh
    } else {
        EjectFreshness::StaleSource { pinned: header.source_hash, actual }
    })
}

/// The `qed validate` re-expansion guard. Given the on-disk generated TOML, the
/// current source bytes, and the freshly-parsed source workflow:
///
/// 1. require a provenance header ([`ValidateError::NotGenerated`] otherwise);
/// 2. fail if the source drifted from the pin ([`ValidateError::SourceDrifted`]);
/// 3. re-eject the source and fail if the generated *body* differs from disk
///    ([`ValidateError::HandEdited`]) — a hand-edit of a generated file.
///
/// On success the on-disk file faithfully reflects its source.
pub fn validate_ejected(
    generated_toml: &str,
    current_source_bytes: &[u8],
    workflow: &Workflow,
) -> Result<(), ValidateError> {
    let header = parse_header(generated_toml).ok_or(ValidateError::NotGenerated)?;

    let actual = content_hash(current_source_bytes);
    if actual != header.source_hash {
        return Err(ValidateError::SourceDrifted { pinned: header.source_hash, actual });
    }

    // Re-expand and compare bodies (header stripped — comments aren't canonical).
    let expected = eject(&header.source, current_source_bytes, workflow);
    if strip_header(&expected) != strip_header(generated_toml) {
        return Err(ValidateError::HandEdited);
    }
    Ok(())
}

/// Render the full ejected document: provenance + flag comment header, then the
/// native pipeline body.
fn render_document(header: &GeneratedHeader, report: &TransformReport) -> String {
    let pipeline = report_to_pipeline(header, report);
    let body = toml::to_string_pretty(&pipeline)
        .unwrap_or_else(|e| panic!("serialize ejected pipeline: {e}"));
    format!("{}\n{body}", render_header(header, report))
}

/// The leading comment block: the machine-readable pin line, a provenance note,
/// and one `# @qed:flag …` line per F4 flag (so tier-3 replacements travel with
/// the generated file).
fn render_header(header: &GeneratedHeader, report: &TransformReport) -> String {
    let mut out = String::new();
    out.push_str(&format!(
        "{HEADER_TAG} source=\"{}\" hash=\"{}\"\n",
        header.source.display(),
        header.source_hash
    ));
    out.push_str("# Generated by `qed eject` (R533-F6, W224). Do not hand-edit: re-eject the\n");
    out.push_str("# source, or delete the source and own this file. `qed validate` re-expands\n");
    out.push_str("# and fails if this body drifts from its source.\n");
    for step in &report.steps {
        for flag in &step.flags {
            out.push_str(&format!(
                "# @qed:flag job={} step={} severity={} -- {}\n",
                step.job,
                step.step_index,
                flag.severity().label(),
                flag_summary(flag),
            ));
        }
    }
    out
}

/// One-line summary of a flag for the header: what it is + the native stanza.
fn flag_summary(flag: &FlagKind) -> String {
    let what = match flag {
        FlagKind::ReplaceWithNative(nr) => format!("tier-3 {}", nr.label()),
        FlagKind::EmbeddedServiceTouch(_) => "embedded service touch".to_string(),
        FlagKind::ToolkitAction { slug, .. } => format!("toolkit action {slug}"),
        FlagKind::Unknown { slug } => format!("unknown action {slug}"),
        FlagKind::UnresolvedExpression => "unresolved expression".to_string(),
        FlagKind::UnbridgedSecret { names } => format!("unbridged secret {}", names.join(", ")),
    };
    format!("{what}: {}", flag.stanza_hint())
}

/// Build a native [`Pipeline`] from a transform report — the ejected body.
fn report_to_pipeline(header: &GeneratedHeader, report: &TransformReport) -> Pipeline {
    Pipeline {
        allow_late_operator_block: false,
        description: None,
        name: report.name.clone(),
        label: report.label.clone(),
        tags: Vec::new(),
        steps: report.collect_native(),
        params: HashMap::new(),
        on_success: Vec::new(),
        on_fail: Vec::new(),
        triggers: Vec::new(),
        concurrency_key: None,
        max_parallel: None,
        environment: Environment::default(),
        workspace: crate::types::WorkspaceMode::default(),
        // Record that this pipeline exists *because* it composes a workflow, so
        // the daemon suppresses the source's auto-ingest (no double catalog
        // entry).
        //
        // The token MUST carry the source's camp-relative PATH, not the
        // pipeline name: `qed_pipelines_handler` builds its suppression set by
        // stripping the `gha:` prefix and matching the remainder against each
        // workflow's `rel_path`. A name slug never matches a path, so ejecting
        // used to leave the source workflow still listed in the run tab
        // alongside its own ejected output — the exact double-entry this field
        // exists to prevent. It is what makes the raw `.github/workflows/*.yml`
        // rows disappear as, and only as, each one is ported.
        wraps: Some(format!("gha:{}", header.source.display())),
        matrix: None,
        toolchain: None,
        binds: Vec::new(),
        on_change: Vec::new(),
        alias_of: None,
        pins: Default::default(),
        finally: Vec::new(),
        participants: None,
    }
}

/// Parse the `# @qed:generated source="…" hash="…"` provenance line out of a
/// document's leading comment header. `None` when absent.
fn parse_header(toml_text: &str) -> Option<GeneratedHeader> {
    let line = toml_text.lines().find(|l| l.trim_start().starts_with(HEADER_TAG))?;
    let source = scan_quoted_field(line, "source=")?;
    let source_hash = scan_quoted_field(line, "hash=")?;
    Some(GeneratedHeader { source: PathBuf::from(source), source_hash })
}

/// Extract a `key="value"` field's value from a header line.
fn scan_quoted_field(line: &str, key: &str) -> Option<String> {
    let after = &line[line.find(key)? + key.len()..];
    let rest = after.strip_prefix('"')?;
    let end = rest.find('"')?;
    Some(rest[..end].to_string())
}

/// Drop the leading run of comment / blank lines — the non-canonical header —
/// leaving the pipeline body for byte-comparison.
fn strip_header(text: &str) -> &str {
    let mut idx = 0;
    for line in text.lines() {
        let t = line.trim_start();
        if t.starts_with('#') || t.is_empty() {
            idx += line.len() + 1; // +1 for the '\n'
        } else {
            break;
        }
    }
    text[idx.min(text.len())..].trim_start_matches('\n')
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dag as yah_qed_dag;

    const WF: &str = r#"
name: Release Flow
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: cargo build --release
"#;

    fn wf(src: &str) -> Workflow {
        yah_qed_gha::parse_workflow(src).expect("parse")
    }

    #[test]
    fn ejected_body_is_loadable_pipeline_toml() {
        let doc = eject(Path::new(".github/workflows/release.yml"), WF.as_bytes(), &wf(WF));
        // Header present and machine-readable.
        assert!(doc.contains("# @qed:generated source="));
        // The tier-3 checkout flag travels in the header.
        assert!(doc.contains("@qed:flag"));
        assert!(doc.to_lowercase().contains("checkout"));
        // The body (header stripped) parses as a normal Pipeline.
        let body = strip_header(&doc);
        let pipeline: Pipeline = toml::from_str(body).expect("ejected body is valid Pipeline TOML");
        assert_eq!(pipeline.name, "release-flow");
        assert_eq!(pipeline.steps.len(), 1, "only the run step is native; checkout is flagged");
        assert_eq!(pipeline.steps[0].name, "build: Build");
    }

    #[test]
    fn wraps_records_the_source_path_so_the_run_tab_suppression_matches() {
        // `qed_pipelines_handler` strips the `gha:` prefix and matches the
        // remainder against each workflow's camp-relative rel_path. Recording
        // the pipeline NAME here (as this did before) never matches, so the
        // ejected pipeline and the workflow it replaces both stayed listed.
        let src = Path::new(".github/workflows/release.yml");
        let doc = eject(src, WF.as_bytes(), &wf(WF));
        let pipeline: Pipeline = toml::from_str(strip_header(&doc)).expect("valid Pipeline TOML");
        assert_eq!(
            pipeline.wraps.as_deref(),
            Some("gha:.github/workflows/release.yml")
        );
        // And specifically NOT the slug, which is what the name-based token
        // would have produced.
        assert_ne!(pipeline.wraps.as_deref(), Some("gha:release-flow"));
    }

    /// R605-F3 — the whole point of the field: a multi-job workflow ejects with
    /// its job graph on the steps, and the graph re-reads out of the TOML into
    /// a plan with genuinely parallel waves instead of a topological line.
    #[test]
    fn a_multi_job_workflow_ejects_the_job_structure_not_a_flat_list() {
        const MULTI: &str = r#"
name: Fan Out
on: push
jobs:
  setup:
    runs-on: x
    steps:
      - name: prep
        run: echo prep
  left:
    needs: setup
    runs-on: x
    steps:
      - name: build
        run: echo left
  right:
    needs: setup
    runs-on: x
    steps:
      - name: build
        run: echo right
  ship:
    needs: [left, right]
    runs-on: x
    steps:
      - name: publish
        run: echo ship
"#;
        let doc = eject(Path::new("wf.yml"), MULTI.as_bytes(), &wf(MULTI));
        assert!(doc.contains("needs = "), "the edges reach the file:\n{doc}");

        let pipeline: Pipeline =
            toml::from_str(strip_header(&doc)).expect("ejected body is valid Pipeline TOML");
        let waves = yah_qed_dag::waves(&pipeline.steps, yah_qed_dag::Missing::Reject)
            .expect("the ejected graph resolves");
        assert_eq!(
            waves.len(),
            3,
            "root, the two branches, the join — not four sequential waves: {waves:?}",
        );
        assert_eq!(waves[1].len(), 2, "the two independent jobs share a wave");
    }

    /// A round trip through TOML must not lose the edges — `needs` is written
    /// as an array *after* several inline tables, which is exactly the shape a
    /// TOML serializer gets wrong if the field lands in the wrong place.
    #[test]
    fn needs_survives_the_toml_round_trip() {
        const TWO: &str = r#"
on: push
jobs:
  a:
    runs-on: x
    steps:
      - name: one
        run: echo a
        env:
          K: v
  b:
    needs: a
    runs-on: x
    steps:
      - name: two
        run: echo b
"#;
        let doc = eject(Path::new("wf.yml"), TWO.as_bytes(), &wf(TWO));
        let pipeline: Pipeline = toml::from_str(strip_header(&doc)).expect("valid TOML");
        assert_eq!(pipeline.steps[0].needs, Some(vec![]));
        assert_eq!(pipeline.steps[1].needs, Some(vec!["a: one".to_string()]));
    }

    #[test]
    fn header_round_trips_through_parse() {
        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
        let h = parse_header(&doc).expect("header parses");
        assert_eq!(h.source, PathBuf::from("wf.yml"));
        assert_eq!(h.source_hash, content_hash(WF.as_bytes()));
        assert_eq!(h.source_hash.len(), 64);
    }

    #[test]
    fn freshness_is_fresh_for_unchanged_source() {
        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
        assert_eq!(freshness(&doc, WF.as_bytes()), Some(EjectFreshness::Fresh));
    }

    #[test]
    fn freshness_is_stale_when_source_drifts() {
        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
        let drifted = format!("{WF}\n# a comment that changes the bytes\n");
        match freshness(&doc, drifted.as_bytes()) {
            Some(EjectFreshness::StaleSource { pinned, actual }) => {
                assert_eq!(pinned, content_hash(WF.as_bytes()));
                assert_eq!(actual, content_hash(drifted.as_bytes()));
                assert_ne!(pinned, actual);
            }
            other => panic!("expected StaleSource, got {other:?}"),
        }
    }

    #[test]
    fn freshness_none_without_header() {
        assert_eq!(freshness("name = \"x\"\nlabel = \"x\"\n", WF.as_bytes()), None);
    }

    #[test]
    fn validate_passes_for_a_fresh_unedited_eject() {
        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
        assert_eq!(validate_ejected(&doc, WF.as_bytes(), &wf(WF)), Ok(()));
    }

    #[test]
    fn validate_flags_a_hand_edited_body() {
        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
        // Tamper with the generated body (not the header).
        let tampered = doc.replace("cargo build --release", "cargo build --release --tampered");
        assert_ne!(tampered, doc);
        assert_eq!(
            validate_ejected(&tampered, WF.as_bytes(), &wf(WF)),
            Err(ValidateError::HandEdited),
        );
    }

    #[test]
    fn validate_reports_source_drift_distinctly_from_hand_edit() {
        let doc = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
        let drifted = format!("{WF}\n# drift\n");
        match validate_ejected(&doc, drifted.as_bytes(), &wf(&drifted)) {
            Err(ValidateError::SourceDrifted { pinned, actual }) => {
                assert_eq!(pinned, content_hash(WF.as_bytes()));
                assert_eq!(actual, content_hash(drifted.as_bytes()));
            }
            other => panic!("expected SourceDrifted, got {other:?}"),
        }
    }

    #[test]
    fn validate_rejects_a_non_generated_file() {
        assert_eq!(
            validate_ejected("name = \"hand\"\nlabel = \"hand\"\n", WF.as_bytes(), &wf(WF)),
            Err(ValidateError::NotGenerated),
        );
    }

    #[test]
    fn eject_is_deterministic() {
        let a = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
        let b = eject(Path::new("wf.yml"), WF.as_bytes(), &wf(WF));
        assert_eq!(a, b, "same source → byte-identical eject (validate relies on this)");
    }
}