g1t/crates/actions/src/workflow.rs

585 lines23,038 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

GitHub Actions on g1t, part one: reading workflows1//! Reading a workflow file: its triggers, jobs and steps, and notes on
2//! anything in it that runs differently on g1t, so moving a repository
3//! from GitHub says plainly what to expect.
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7
8use crate::filter::{Filter, Patterns};
9
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs10/// Where workflows live: GitHub's `.github/workflows`, under g1t's own
11/// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
12/// g1t never reads `.github`, which stays GitHub's.
13pub const FOLDER: &str = ".g1t/workflows";
GitHub Actions on g1t, part one: reading workflows14
15/// The events a workflow can name that g1t starts runs for.
16pub const SUPPORTED_EVENTS: &[&str] = &[
17 "push",
18 "pull_request",
19 "pull_request_target",
20 "pull_request_review",
21 "issues",
22 "issue_comment",
23 "schedule",
24 "workflow_dispatch",
25 "repository_dispatch",
26 "workflow_call",
27 "merge_group",
28 "create",
29 "delete",
30];
31
32/// The `types` each event has when a workflow gives none, as on GitHub.
33pub fn default_types(event: &str) -> &'static [&'static str] {
34 match event {
35 "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"],
36 "merge_group" => &["checks_requested"],
37 _ => &[],
38 }
39}
40
41/// How much a note matters.
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum Severity {
45 /// Runs, slightly differently.
46 Info,
47 /// Runs, but something in it does nothing or may not work.
48 Warning,
49 /// Does not run on g1t.
50 Unsupported,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Note {
55 pub severity: Severity,
56 /// The job, if the note is about one.
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub job: Option<String>,
59 pub message: String,
60}
61
62/// One event a workflow is started by, with its filters.
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct Trigger {
65 pub event: String,
66 /// Activity types; empty means the event's defaults (or all).
67 pub types: Vec<String>,
68 pub branches: Filter,
69 pub tags: Filter,
70 pub paths: Filter,
71 /// For `schedule`.
72 pub crons: Vec<String>,
73 /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
74 pub inputs: Map<String, Value>,
75}
76
77impl Trigger {
78 /// Whether an activity type starts it.
79 pub fn wants_type(&self, action: Option<&str>) -> bool {
80 let Some(action) = action else { return true };
81 if self.types.is_empty() {
A repository has its own sidebar, as settings do82 // A g1t agent's pull request has no code until it is marked
83 // ready, so that is when its default runs start, as `opened`
84 // would on GitHub.
85 if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" {
86 return true;
87 }
GitHub Actions on g1t, part one: reading workflows88 let defaults = default_types(&self.event);
89 return defaults.is_empty() || defaults.contains(&action);
90 }
91 self.types.iter().any(|t| t == action)
92 }
93}
94
95#[derive(Clone, Debug, PartialEq)]
96pub struct Step {
97 pub id: Option<String>,
98 pub name: Option<String>,
99 pub condition: Option<String>,
100 pub uses: Option<String>,
101 pub run: Option<String>,
102 /// The whole step as written, for the sandbox.
103 pub raw: Value,
104}
105
106impl Step {
107 /// How the step is shown when it has no name.
108 pub fn title(&self) -> String {
109 if let Some(name) = &self.name {
110 return name.clone();
111 }
112 if let Some(uses) = &self.uses {
113 return format!("Run {uses}");
114 }
115 let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
116 format!("Run {}", first.trim())
117 }
118}
119
120#[derive(Clone, Debug, PartialEq)]
121pub struct Job {
122 /// Its key under `jobs:`.
123 pub id: String,
124 pub name: Option<String>,
125 pub needs: Vec<String>,
126 pub condition: Option<String>,
127 pub runs_on: Value,
128 /// `strategy.matrix`, as written (it may be an expression).
129 pub matrix: Option<Value>,
130 pub fail_fast: bool,
131 pub max_parallel: Option<u32>,
132 /// A reusable workflow it calls (`uses:` on a job).
133 pub uses: Option<String>,
134 pub steps: Vec<Step>,
135 /// The whole job as written, for the sandbox.
136 pub raw: Value,
137}
138
139#[derive(Clone, Debug, PartialEq)]
140pub struct Workflow {
141 pub name: Option<String>,
142 pub run_name: Option<String>,
143 pub triggers: Vec<Trigger>,
144 pub env: Map<String, Value>,
145 pub concurrency: Option<Concurrency>,
146 pub jobs: Vec<Job>,
147 pub notes: Vec<Note>,
148 /// The whole workflow as written.
149 pub raw: Value,
150}
151
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub struct Concurrency {
154 /// May hold an expression.
155 pub group: String,
156 pub cancel_in_progress: Value,
157}
158
159impl Workflow {
160 pub fn trigger(&self, event: &str) -> Option<&Trigger> {
161 self.triggers.iter().find(|trigger| trigger.event == event)
162 }
163
164 /// The name shown for it: its `name`, or its file's path.
165 pub fn display_name(&self, path: &str) -> String {
166 self.name.clone().unwrap_or_else(|| path.to_owned())
167 }
168
169 /// The job ids in an order where each comes after the jobs it needs.
170 pub fn job_order(&self) -> Vec<&str> {
171 let mut ordered: Vec<&str> = Vec::new();
172 while ordered.len() < self.jobs.len() {
173 let before = ordered.len();
174 for job in &self.jobs {
175 if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
176 ordered.push(&job.id);
177 }
178 }
179 if ordered.len() == before {
180 break;
181 }
182 }
183 ordered
184 }
185}
186
187/// YAML to JSON, keeping the order of keys. Keys that are not strings
188/// (`on: true` in YAML 1.1, numbers) become their text.
189pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
190 match value {
191 serde_yaml::Value::Null => Value::Null,
192 serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
193 serde_yaml::Value::Number(number) => {
194 if let Some(n) = number.as_i64() {
195 Value::from(n)
196 } else if let Some(n) = number.as_u64() {
197 Value::from(n)
198 } else {
199 number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
200 }
201 }
202 serde_yaml::Value::String(text) => Value::String(text.clone()),
203 serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
204 serde_yaml::Value::Mapping(map) => {
205 let mut out = Map::new();
206 for (key, value) in map {
207 let key = match key {
208 serde_yaml::Value::String(text) => text.clone(),
209 serde_yaml::Value::Bool(flag) => flag.to_string(),
210 serde_yaml::Value::Number(number) => number.to_string(),
211 _ => continue,
212 };
213 out.insert(key, yaml_to_json(value));
214 }
215 Value::Object(out)
216 }
217 serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
218 }
219}
220
221fn texts(value: Option<&Value>) -> Vec<String> {
222 match value {
223 Some(Value::String(text)) => vec![text.clone()],
224 Some(Value::Array(items)) => items
225 .iter()
226 .filter_map(|item| match item {
227 Value::String(text) => Some(text.clone()),
228 Value::Number(n) => Some(n.to_string()),
229 _ => None,
230 })
231 .collect(),
232 _ => Vec::new(),
233 }
234}
235
236fn text(value: Option<&Value>) -> Option<String> {
237 match value? {
238 Value::String(text) => Some(text.clone()),
239 Value::Number(n) => Some(n.to_string()),
240 Value::Bool(flag) => Some(flag.to_string()),
241 _ => None,
242 }
243}
244
245fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
246 let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
247 Filter { only: list(only), ignore: list(ignore) }
248}
249
250fn trigger(event: &str, spec: &Value) -> Trigger {
251 let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
252 match spec {
253 Value::Object(spec) => {
254 trigger.types = texts(spec.get("types"));
255 trigger.branches = filter(spec, "branches", "branches-ignore");
256 trigger.tags = filter(spec, "tags", "tags-ignore");
257 trigger.paths = filter(spec, "paths", "paths-ignore");
258 if let Some(Value::Object(inputs)) = spec.get("inputs") {
259 trigger.inputs = inputs.clone();
260 }
261 }
262 Value::Array(entries) if event == "schedule" => {
263 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
264 }
265 _ => {}
266 }
267 trigger
268}
269
270/// Reads a workflow. `Err` is what is wrong with the file, for the person
271/// who wrote it; what reads but runs differently is in `notes`.
272pub fn parse(source: &str) -> Result<Workflow, String> {
273 let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
274 let raw = yaml_to_json(&yaml);
275 let Value::Object(root) = &raw else {
276 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
277 };
278 let mut notes = Vec::new();
279 let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
280
281 // `on`, in any of its three shapes. YAML 1.1 readers turn `on` into
282 // `true`; this reader keeps it, and accepts both.
283 let on = root.get("on").or_else(|| root.get("true")).ok_or("`on` is missing: say which events start the workflow.")?;
284 let mut triggers = Vec::new();
285 match on {
286 Value::String(event) => triggers.push(trigger(event, &Value::Null)),
287 Value::Array(events) => {
288 for event in events {
289 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
290 triggers.push(trigger(event, &Value::Null));
291 }
292 }
293 Value::Object(events) => {
294 for (event, spec) in events {
295 triggers.push(trigger(event, spec));
296 }
297 }
298 _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
299 }
300 for trigger in &triggers {
301 if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
302 note(
303 Severity::Unsupported,
304 None,
305 format!("g1t has no `{}` event, so that trigger never starts it.", trigger.event),
306 );
307 }
308 if trigger.event == "pull_request_target" {
309 note(
310 Severity::Info,
311 None,
312 "`pull_request_target` runs like `pull_request`, on the pull request's head, with the repository's secrets.".to_owned(),
313 );
314 }
315 if trigger.event == "workflow_call" && triggers.len() == 1 {
316 note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
317 }
318 }
319
320 let env = match root.get("env") {
321 Some(Value::Object(env)) => env.clone(),
322 _ => Map::new(),
323 };
324 let concurrency = match root.get("concurrency") {
325 Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
326 Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
327 group,
328 cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
329 }),
330 _ => None,
331 };
332
333 let Some(Value::Object(job_specs)) = root.get("jobs") else {
334 return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
335 };
336 if job_specs.is_empty() {
337 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
338 }
339 let mut jobs = Vec::new();
340 for (id, spec) in job_specs {
341 let Value::Object(spec) = spec else {
342 return Err(format!("Job `{id}` is a mapping."));
343 };
344 let uses = text(spec.get("uses"));
345 let steps_raw = match spec.get("steps") {
346 Some(Value::Array(steps)) => steps.clone(),
347 None if uses.is_some() => Vec::new(),
348 None => return Err(format!("Job `{id}` has no `steps`.")),
349 Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
350 };
351 let mut steps = Vec::new();
352 for (index, step) in steps_raw.iter().enumerate() {
353 let Value::Object(fields) = step else {
354 return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
355 };
356 let step = Step {
357 id: text(fields.get("id")),
358 name: text(fields.get("name")),
359 condition: text(fields.get("if")),
360 uses: text(fields.get("uses")),
361 run: text(fields.get("run")),
362 raw: step.clone(),
363 };
364 match (&step.uses, &step.run) {
365 (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
366 (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
367 _ => {}
368 }
369 if let Some(uses) = &step.uses
370 && let Some((severity, message)) = action_note(uses)
371 {
372 note(severity, Some(id), message);
373 }
374 if let Some(shell) = text(fields.get("shell"))
375 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
376 {
377 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
378 }
379 steps.push(step);
380 }
381 let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
382 for label in texts(Some(&runs_on)).iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default().iter()) {
383 let lower = label.to_ascii_lowercase();
384 if lower.contains("windows") || lower.contains("macos") {
385 note(
386 Severity::Unsupported,
387 Some(id),
388 format!("`runs-on: {label}`: g1t runs jobs on Linux only, so this job fails."),
389 );
390 } else if lower == "self-hosted" {
391 note(Severity::Info, Some(id), "`self-hosted`: g1t runs it on its own Linux runner.".to_owned());
392 }
393 }
394 if spec.contains_key("services") {
395 note(Severity::Unsupported, Some(id), "`services` containers (such as a database) are not started on g1t yet.".to_owned());
396 }
397 if spec.contains_key("container") {
398 note(Severity::Warning, Some(id), "`container`: steps run on g1t's runner image instead of that container.".to_owned());
399 }
400 if spec.contains_key("environment") {
401 note(Severity::Info, Some(id), "`environment`: protection rules are not enforced on g1t yet; the job runs with the repository's secrets.".to_owned());
402 }
403 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
404 Some(Value::Object(strategy)) => (
405 strategy.get("matrix").cloned(),
406 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
407 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
408 ),
409 _ => (None, true, None),
410 };
GitHub Actions on g1t, part two: running workflows411 if uses.is_some() {
412 note(Severity::Unsupported, Some(id), "Reusable workflows (`uses:` on a job) are not called on g1t yet, so this job fails.".to_owned());
GitHub Actions on g1t, part one: reading workflows413 }
414 jobs.push(Job {
415 id: id.clone(),
416 name: text(spec.get("name")),
417 needs: texts(spec.get("needs")),
418 condition: text(spec.get("if")),
419 runs_on,
420 matrix,
421 fail_fast,
422 max_parallel,
423 uses,
424 steps,
425 raw: Value::Object(spec.clone()),
426 });
427 }
428 for job in &jobs {
429 for need in &job.needs {
430 if !jobs.iter().any(|other| &other.id == need) {
431 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
432 }
433 }
434 }
435 let workflow = Workflow {
436 name: text(root.get("name")),
437 run_name: text(root.get("run-name")),
438 triggers,
439 env,
440 concurrency,
441 jobs,
442 notes,
443 raw,
444 };
445 if workflow.job_order().len() < workflow.jobs.len() {
446 return Err("The jobs' `needs` go round in a circle.".to_owned());
447 }
448 Ok(workflow)
449}
450
451/// What to say about an action g1t runs differently, if anything.
452fn action_note(uses: &str) -> Option<(Severity, String)> {
453 if uses.starts_with("docker://") {
454 return Some((Severity::Unsupported, format!("`{uses}`: Docker actions do not run on g1t yet.")));
455 }
456 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
457 match name.as_str() {
458 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
459 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
460 Severity::Warning,
461 format!("`{name}`: g1t has no cache yet, so it always misses and the job does the work again."),
462 )),
463 "actions/upload-artifact" | "actions/download-artifact" => Some((
464 Severity::Warning,
465 format!("`{name}`: artifacts are kept for the run on g1t, and passed between its jobs."),
466 )),
467 _ => None,
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 const CI: &str = r#"
476name: CI
477on:
478 push:
479 branches: [main]
480 paths-ignore: ["docs/**"]
481 pull_request:
482 workflow_dispatch:
483 inputs:
484 debug:
485 type: boolean
486 default: false
487 schedule:
488 - cron: "0 3 * * *"
489concurrency:
490 group: ci-${{ github.ref }}
491 cancel-in-progress: true
492env:
493 CARGO_TERM_COLOR: always
494jobs:
495 test:
496 runs-on: ${{ matrix.os }}
497 strategy:
498 matrix:
499 os: [ubuntu-latest, windows-latest]
500 node: [18, 20]
501 steps:
502 - uses: actions/checkout@v4
503 - uses: actions/setup-node@v4
504 with:
505 node-version: ${{ matrix.node }}
506 - run: npm ci
507 - name: Test
508 run: npm test
509 deploy:
510 needs: test
511 if: github.ref == 'refs/heads/main'
512 runs-on: ubuntu-latest
513 steps:
514 - run: echo deploy
515"#;
516
517 #[test]
518 fn a_whole_workflow_reads() {
519 let workflow = parse(CI).unwrap();
520 assert_eq!(workflow.name.as_deref(), Some("CI"));
521 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
522 let push = workflow.trigger("push").unwrap();
523 assert!(push.branches.allows("main"));
524 assert!(!push.branches.allows("dev"));
525 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
526 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
527 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
528 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
529 assert_eq!(workflow.jobs.len(), 2);
530 assert_eq!(workflow.jobs[1].needs, ["test"]);
531 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v4");
532 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
533 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
534 assert_eq!(workflow.job_order(), ["test", "deploy"]);
535 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
536 }
537
538 #[test]
539 fn short_forms_of_on() {
540 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
541 assert_eq!(one.triggers[0].event, "push");
542 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
543 assert_eq!(list.triggers.len(), 2);
544 let pr = list.trigger("pull_request").unwrap();
545 assert!(pr.wants_type(Some("opened")));
546 assert!(pr.wants_type(Some("synchronize")));
547 assert!(!pr.wants_type(Some("closed")));
A repository has its own sidebar, as settings do548 assert!(pr.wants_type(Some("ready_for_review")));
GitHub Actions on g1t, part one: reading workflows549 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
550 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
551 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
552 }
553
554 #[test]
555 fn notes_say_what_runs_differently() {
556 let workflow = parse(
557 "on: [push, release]\njobs:\n win:\n runs-on: windows-latest\n services:\n db: { image: postgres }\n steps:\n - uses: actions/cache@v4\n - uses: docker://alpine\n - run: dir\n shell: pwsh",
558 )
559 .unwrap();
560 let unsupported: Vec<&str> =
561 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
562 assert!(unsupported.iter().any(|m| m.contains("`release`")));
563 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
564 assert!(unsupported.iter().any(|m| m.contains("services")));
565 assert!(unsupported.iter().any(|m| m.contains("docker://alpine")));
566 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
567 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/cache")));
568 }
569
570 #[test]
571 fn mistakes_are_explained() {
572 let problem = |yaml: &str| parse(yaml).unwrap_err();
573 assert!(problem("jobs: {}").contains("`on` is missing"));
574 assert!(problem("on: push").contains("`jobs` is missing"));
575 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
576 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
577 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
578 assert!(
579 problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]\n b:\n needs: a\n runs-on: x\n steps: [{ run: x }]")
580 .contains("circle")
581 );
582 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
583 assert!(problem(": : :").contains("not valid YAML"));
584 }
585}