Skip to content

g1t/crates/actions/src/workflow.rs

784 lines32,998 bytesCodeBlameRaw

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};
Merge branch 'worktree-agent-a3abfcce648e87dca'9use crate::permissions::{self, Permissions};
GitHub Actions on g1t, part one: reading workflows10
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs11/// Where workflows live: GitHub's `.github/workflows`, under g1t's own
12/// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
13/// g1t never reads `.github`, which stays GitHub's.
14pub const FOLDER: &str = ".g1t/workflows";
GitHub Actions on g1t, part one: reading workflows15
16/// The events a workflow can name that g1t starts runs for.
17pub const SUPPORTED_EVENTS: &[&str] = &[
18 "push",
19 "pull_request",
20 "pull_request_target",
21 "pull_request_review",
22 "issues",
23 "issue_comment",
24 "schedule",
25 "workflow_dispatch",
26 "repository_dispatch",
27 "workflow_call",
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 2428 "workflow_run",
GitHub Actions on g1t, part one: reading workflows29 "merge_group",
30 "create",
31];
32
Merge branch 'worktree-agent-a3abfcce648e87dca'33/// Events GitHub has that g1t knows of but never sends: a workflow on one
34/// of them is told so, rather than waiting for a run that never comes.
35pub const UNSENT_EVENTS: &[(&str, &str)] = &[(
36 "delete",
37 "g1t does not start runs when a branch or tag is deleted yet, so the `delete` trigger never starts it. New branches and tags start `create` and `push` workflows.",
38)];
39
GitHub Actions on g1t, part one: reading workflows40/// The `types` each event has when a workflow gives none, as on GitHub.
41pub fn default_types(event: &str) -> &'static [&'static str] {
42 match event {
43 "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"],
44 "merge_group" => &["checks_requested"],
45 _ => &[],
46 }
47}
48
49/// How much a note matters.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Severity {
53 /// Runs, slightly differently.
54 Info,
55 /// Runs, but something in it does nothing or may not work.
56 Warning,
57 /// Does not run on g1t.
58 Unsupported,
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Note {
63 pub severity: Severity,
64 /// The job, if the note is about one.
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub job: Option<String>,
67 pub message: String,
68}
69
70/// One event a workflow is started by, with its filters.
71#[derive(Clone, Debug, Default, PartialEq, Eq)]
72pub struct Trigger {
73 pub event: String,
74 /// Activity types; empty means the event's defaults (or all).
75 pub types: Vec<String>,
76 pub branches: Filter,
77 pub tags: Filter,
78 pub paths: Filter,
79 /// For `schedule`.
80 pub crons: Vec<String>,
81 /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
82 pub inputs: Map<String, Value>,
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 2483 /// For `workflow_run`: the names of the workflows it follows.
84 pub workflows: Vec<String>,
GitHub Actions on g1t, part one: reading workflows85}
86
87impl Trigger {
88 /// Whether an activity type starts it.
89 pub fn wants_type(&self, action: Option<&str>) -> bool {
90 let Some(action) = action else { return true };
91 if self.types.is_empty() {
A repository has its own sidebar, as settings do92 // A g1t agent's pull request has no code until it is marked
93 // ready, so that is when its default runs start, as `opened`
94 // would on GitHub.
95 if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" {
96 return true;
97 }
GitHub Actions on g1t, part one: reading workflows98 let defaults = default_types(&self.event);
99 return defaults.is_empty() || defaults.contains(&action);
100 }
101 self.types.iter().any(|t| t == action)
102 }
103}
104
105#[derive(Clone, Debug, PartialEq)]
106pub struct Step {
107 pub id: Option<String>,
108 pub name: Option<String>,
109 pub condition: Option<String>,
110 pub uses: Option<String>,
111 pub run: Option<String>,
112 /// The whole step as written, for the sandbox.
113 pub raw: Value,
114}
115
116impl Step {
117 /// How the step is shown when it has no name.
118 pub fn title(&self) -> String {
119 if let Some(name) = &self.name {
120 return name.clone();
121 }
122 if let Some(uses) = &self.uses {
123 return format!("Run {uses}");
124 }
125 let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
126 format!("Run {}", first.trim())
127 }
128}
129
130#[derive(Clone, Debug, PartialEq)]
131pub struct Job {
132 /// Its key under `jobs:`.
133 pub id: String,
134 pub name: Option<String>,
135 pub needs: Vec<String>,
136 pub condition: Option<String>,
137 pub runs_on: Value,
138 /// `strategy.matrix`, as written (it may be an expression).
139 pub matrix: Option<Value>,
140 pub fail_fast: bool,
141 pub max_parallel: Option<u32>,
142 /// A reusable workflow it calls (`uses:` on a job).
143 pub uses: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'144 /// Its own `permissions`, which replace the workflow's.
145 pub permissions: Option<Permissions>,
146 /// Its own `concurrency`: at most one job of its group runs at a time.
147 pub concurrency: Option<Concurrency>,
GitHub Actions on g1t, part one: reading workflows148 pub steps: Vec<Step>,
149 /// The whole job as written, for the sandbox.
150 pub raw: Value,
151}
152
Merge branch 'worktree-agent-a3abfcce648e87dca'153impl Job {
154 /// What its token may do: its own `permissions`, else its workflow's,
155 /// else `default` (the repository's choice).
156 pub fn permissions(&self, workflow: &Workflow, default: permissions::TokenDefault) -> Permissions {
157 self.permissions
158 .clone()
159 .or_else(|| workflow.permissions.clone())
160 .unwrap_or_else(|| Permissions::default_for(default))
161 }
162}
163
GitHub Actions on g1t, part one: reading workflows164#[derive(Clone, Debug, PartialEq)]
165pub struct Workflow {
166 pub name: Option<String>,
167 pub run_name: Option<String>,
168 pub triggers: Vec<Trigger>,
169 pub env: Map<String, Value>,
170 pub concurrency: Option<Concurrency>,
Merge branch 'worktree-agent-a3abfcce648e87dca'171 /// Its top-level `permissions`, for every job that writes none.
172 pub permissions: Option<Permissions>,
GitHub Actions on g1t, part one: reading workflows173 pub jobs: Vec<Job>,
174 pub notes: Vec<Note>,
175 /// The whole workflow as written.
176 pub raw: Value,
177}
178
179#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct Concurrency {
181 /// May hold an expression.
182 pub group: String,
183 pub cancel_in_progress: Value,
184}
185
186impl Workflow {
187 pub fn trigger(&self, event: &str) -> Option<&Trigger> {
188 self.triggers.iter().find(|trigger| trigger.event == event)
189 }
190
191 /// The name shown for it: its `name`, or its file's path.
192 pub fn display_name(&self, path: &str) -> String {
193 self.name.clone().unwrap_or_else(|| path.to_owned())
194 }
195
196 /// The job ids in an order where each comes after the jobs it needs.
197 pub fn job_order(&self) -> Vec<&str> {
198 let mut ordered: Vec<&str> = Vec::new();
199 while ordered.len() < self.jobs.len() {
200 let before = ordered.len();
201 for job in &self.jobs {
202 if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
203 ordered.push(&job.id);
204 }
205 }
206 if ordered.len() == before {
207 break;
208 }
209 }
210 ordered
211 }
212}
213
214/// YAML to JSON, keeping the order of keys. Keys that are not strings
215/// (`on: true` in YAML 1.1, numbers) become their text.
216pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
217 match value {
218 serde_yaml::Value::Null => Value::Null,
219 serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
220 serde_yaml::Value::Number(number) => {
221 if let Some(n) = number.as_i64() {
222 Value::from(n)
223 } else if let Some(n) = number.as_u64() {
224 Value::from(n)
225 } else {
226 number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
227 }
228 }
229 serde_yaml::Value::String(text) => Value::String(text.clone()),
230 serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
231 serde_yaml::Value::Mapping(map) => {
232 let mut out = Map::new();
233 for (key, value) in map {
234 let key = match key {
235 serde_yaml::Value::String(text) => text.clone(),
236 serde_yaml::Value::Bool(flag) => flag.to_string(),
237 serde_yaml::Value::Number(number) => number.to_string(),
238 _ => continue,
239 };
240 out.insert(key, yaml_to_json(value));
241 }
242 Value::Object(out)
243 }
244 serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
245 }
246}
247
248fn texts(value: Option<&Value>) -> Vec<String> {
249 match value {
250 Some(Value::String(text)) => vec![text.clone()],
251 Some(Value::Array(items)) => items
252 .iter()
253 .filter_map(|item| match item {
254 Value::String(text) => Some(text.clone()),
255 Value::Number(n) => Some(n.to_string()),
256 _ => None,
257 })
258 .collect(),
259 _ => Vec::new(),
260 }
261}
262
263fn text(value: Option<&Value>) -> Option<String> {
264 match value? {
265 Value::String(text) => Some(text.clone()),
266 Value::Number(n) => Some(n.to_string()),
267 Value::Bool(flag) => Some(flag.to_string()),
268 _ => None,
269 }
270}
271
272fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
273 let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
274 Filter { only: list(only), ignore: list(ignore) }
275}
276
277fn trigger(event: &str, spec: &Value) -> Trigger {
278 let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
279 match spec {
280 Value::Object(spec) => {
281 trigger.types = texts(spec.get("types"));
282 trigger.branches = filter(spec, "branches", "branches-ignore");
283 trigger.tags = filter(spec, "tags", "tags-ignore");
284 trigger.paths = filter(spec, "paths", "paths-ignore");
285 if let Some(Value::Object(inputs)) = spec.get("inputs") {
286 trigger.inputs = inputs.clone();
287 }
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24288 trigger.workflows = texts(spec.get("workflows"));
GitHub Actions on g1t, part one: reading workflows289 }
290 Value::Array(entries) if event == "schedule" => {
291 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
292 }
293 _ => {}
294 }
295 trigger
296}
297
298/// Reads a workflow. `Err` is what is wrong with the file, for the person
299/// who wrote it; what reads but runs differently is in `notes`.
300pub fn parse(source: &str) -> Result<Workflow, String> {
301 let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
302 let raw = yaml_to_json(&yaml);
303 let Value::Object(root) = &raw else {
304 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
305 };
306 let mut notes = Vec::new();
307 let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
308
309 // `on`, in any of its three shapes. YAML 1.1 readers turn `on` into
310 // `true`; this reader keeps it, and accepts both.
311 let on = root.get("on").or_else(|| root.get("true")).ok_or("`on` is missing: say which events start the workflow.")?;
312 let mut triggers = Vec::new();
313 match on {
314 Value::String(event) => triggers.push(trigger(event, &Value::Null)),
315 Value::Array(events) => {
316 for event in events {
317 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
318 triggers.push(trigger(event, &Value::Null));
319 }
320 }
321 Value::Object(events) => {
322 for (event, spec) in events {
323 triggers.push(trigger(event, spec));
324 }
325 }
326 _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
327 }
328 for trigger in &triggers {
Merge branch 'worktree-agent-a3abfcce648e87dca'329 if let Some((_, why)) = UNSENT_EVENTS.iter().find(|(event, _)| *event == trigger.event) {
330 note(Severity::Unsupported, None, (*why).to_owned());
331 } else if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
GitHub Actions on g1t, part one: reading workflows332 note(
333 Severity::Unsupported,
334 None,
335 format!("g1t has no `{}` event, so that trigger never starts it.", trigger.event),
336 );
337 }
338 if trigger.event == "pull_request_target" {
339 note(
340 Severity::Info,
341 None,
Merge branch 'worktree-agent-a3abfcce648e87dca'342 "`pull_request_target` runs in the base's context: the default branch's copy of this workflow, at the default branch's head, with the repository's secrets. It does not check out the pull request's changes; a step that does runs code anyone could have written, with those secrets.".to_owned(),
GitHub Actions on g1t, part one: reading workflows343 );
344 }
345 if trigger.event == "workflow_call" && triggers.len() == 1 {
346 note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
347 }
348 }
349
350 let env = match root.get("env") {
351 Some(Value::Object(env)) => env.clone(),
352 _ => Map::new(),
353 };
Merge branch 'worktree-agent-a3abfcce648e87dca'354 let concurrency = concurrency_of(root.get("concurrency"));
355 let permissions = match root.get("permissions") {
356 None => None,
357 Some(value) => {
358 let (permissions, unknown) = permissions::parse(value)?;
359 permission_notes(&unknown, None, &mut note);
360 Some(permissions)
361 }
GitHub Actions on g1t, part one: reading workflows362 };
363
364 let Some(Value::Object(job_specs)) = root.get("jobs") else {
365 return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
366 };
367 if job_specs.is_empty() {
368 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
369 }
370 let mut jobs = Vec::new();
371 for (id, spec) in job_specs {
372 let Value::Object(spec) = spec else {
373 return Err(format!("Job `{id}` is a mapping."));
374 };
375 let uses = text(spec.get("uses"));
376 let steps_raw = match spec.get("steps") {
377 Some(Value::Array(steps)) => steps.clone(),
378 None if uses.is_some() => Vec::new(),
379 None => return Err(format!("Job `{id}` has no `steps`.")),
380 Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
381 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily382 let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
383 let labels: Vec<String> =
384 texts(Some(&runs_on)).into_iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default()).collect();
385 // `self-hosted`, or a runner group, sends the job to the workspace's
386 // own runners, which may be Linux, macOS or Windows.
387 let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted"));
GitHub Actions on g1t, part one: reading workflows388 let mut steps = Vec::new();
389 for (index, step) in steps_raw.iter().enumerate() {
390 let Value::Object(fields) = step else {
391 return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
392 };
393 let step = Step {
394 id: text(fields.get("id")),
395 name: text(fields.get("name")),
396 condition: text(fields.get("if")),
397 uses: text(fields.get("uses")),
398 run: text(fields.get("run")),
399 raw: step.clone(),
400 };
401 match (&step.uses, &step.run) {
402 (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
403 (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
404 _ => {}
405 }
406 if let Some(uses) = &step.uses
Actions: workflow notes say what the cache and artifacts do now407 && let Some((severity, message)) = action_note(uses, fields.get("with").and_then(|with| with.get("cache")).is_some())
GitHub Actions on g1t, part one: reading workflows408 {
409 note(severity, Some(id), message);
410 }
411 if let Some(shell) = text(fields.get("shell"))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily412 && !self_hosted
GitHub Actions on g1t, part one: reading workflows413 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
414 {
415 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
416 }
417 steps.push(step);
418 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily419 if self_hosted {
420 note(
421 Severity::Info,
422 Some(id),
423 "`self-hosted`: the job runs on one of the workspace's self-hosted runners that has every label in its `runs-on`, and waits until one does.".to_owned(),
424 );
425 } else {
426 for label in &labels {
427 let lower = label.to_ascii_lowercase();
428 if lower.contains("windows") || lower.contains("macos") {
429 note(
430 Severity::Unsupported,
431 Some(id),
432 format!("`runs-on: {label}`: g1t's own runners are Linux only, so this job fails. To run it on a Windows or macOS machine of your own, add a self-hosted runner and use `runs-on: [self-hosted, ...]`."),
433 );
434 }
GitHub Actions on g1t, part one: reading workflows435 }
436 }
437 if spec.contains_key("services") {
Merge branch 'main' into actions-toolkit-oidc-artifacts438 note(
439 Severity::Info,
440 Some(id),
441 "`services`: each service runs in Docker beside the steps and is reached at `localhost:<port>`. On g1t's machines it is the job's own Docker Engine, the service is also reached by its name, and two services cannot listen on the same port.".to_owned(),
442 );
GitHub Actions on g1t, part one: reading workflows443 }
444 if spec.contains_key("container") {
Merge branch 'main' into actions-toolkit-oidc-artifacts445 note(
446 Severity::Info,
447 Some(id),
448 "`container`: the steps run inside that image, in Docker (on g1t's machines, the job's own Engine), with the workspace at the same path as on the runner (`/home/runner/work`), not `/__w`.".to_owned(),
449 );
GitHub Actions on g1t, part one: reading workflows450 }
451 if spec.contains_key("environment") {
Merge branch 'worktree-agent-a3abfcce648e87dca'452 note(Severity::Info, Some(id), "`environment`: the job gets the values its secrets and variables give this environment once the environment's protection rules (required reviewers, a wait timer, which branches may deploy) let it through. Unless it says `deployment: false`, the run records a deployment to it.".to_owned());
GitHub Actions on g1t, part one: reading workflows453 }
Merge branch 'worktree-agent-a3abfcce648e87dca'454 let job_permissions = match spec.get("permissions") {
455 None => None,
456 Some(value) => {
457 let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?;
458 permission_notes(&unknown, Some(id), &mut note);
459 Some(permissions)
460 }
461 };
GitHub Actions on g1t, part one: reading workflows462 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
463 Some(Value::Object(strategy)) => (
464 strategy.get("matrix").cloned(),
465 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
466 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
467 ),
468 _ => (None, true, None),
469 };
Actions: reusable workflows in the repository470 if uses.as_deref().is_some_and(|uses| !uses.starts_with("./")) {
471 note(
472 Severity::Unsupported,
473 Some(id),
474 "Reusable workflows from other repositories are not called on g1t yet, so this job fails; ones in this repository (`./.g1t/workflows/…`) are.".to_owned(),
475 );
GitHub Actions on g1t, part one: reading workflows476 }
477 jobs.push(Job {
478 id: id.clone(),
479 name: text(spec.get("name")),
480 needs: texts(spec.get("needs")),
481 condition: text(spec.get("if")),
482 runs_on,
483 matrix,
484 fail_fast,
485 max_parallel,
486 uses,
Merge branch 'worktree-agent-a3abfcce648e87dca'487 permissions: job_permissions,
488 concurrency: concurrency_of(spec.get("concurrency")),
GitHub Actions on g1t, part one: reading workflows489 steps,
490 raw: Value::Object(spec.clone()),
491 });
492 }
493 for job in &jobs {
494 for need in &job.needs {
495 if !jobs.iter().any(|other| &other.id == need) {
496 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
497 }
498 }
499 }
500 let workflow = Workflow {
501 name: text(root.get("name")),
502 run_name: text(root.get("run-name")),
503 triggers,
504 env,
505 concurrency,
Merge branch 'worktree-agent-a3abfcce648e87dca'506 permissions,
GitHub Actions on g1t, part one: reading workflows507 jobs,
508 notes,
509 raw,
510 };
511 if workflow.job_order().len() < workflow.jobs.len() {
512 return Err("The jobs' `needs` go round in a circle.".to_owned());
513 }
514 Ok(workflow)
515}
516
Merge branch 'worktree-agent-a3abfcce648e87dca'517/// `concurrency`, as a group's name or a mapping with `group` and
518/// `cancel-in-progress`.
519fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> {
520 match value {
521 Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
522 Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
523 group,
524 cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
525 }),
526 _ => None,
527 }
528}
529
530/// What to say about `permissions` names the token does not have.
531fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
532 for name in unknown {
533 note(Severity::Warning, job, format!("`permissions.{name}`: the token has no permission called that, so it grants nothing."));
534 }
535}
536
GitHub Actions on g1t, part one: reading workflows537/// What to say about an action g1t runs differently, if anything.
Actions: workflow notes say what the cache and artifacts do now538/// `caches`: the step sets a `cache` input.
539fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> {
GitHub Actions on g1t, part one: reading workflows540 if uses.starts_with("docker://") {
Merge branch 'main' into actions-toolkit-oidc-artifacts541 return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine).")));
GitHub Actions on g1t, part one: reading workflows542 }
543 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
544 match name.as_str() {
545 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
546 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
Actions: workflow notes say what the cache and artifacts do now547 Severity::Info,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily548 format!("`{name}`: g1t keeps the cache per repository: up to 2 GiB an entry and 10 GiB a repository, until it goes 7 days unused, and at most 28 days."),
GitHub Actions on g1t, part one: reading workflows549 )),
550 "actions/upload-artifact" | "actions/download-artifact" => Some((
Actions: workflow notes say what the cache and artifacts do now551 Severity::Info,
552 format!("`{name}`: g1t keeps artifacts with the run for 14 days, up to 60 MB each."),
553 )),
554 _ if caches && name.starts_with("actions/setup-") => Some((
GitHub Actions on g1t, part one: reading workflows555 Severity::Warning,
Actions: workflow notes say what the cache and artifacts do now556 format!("`{name}` with `cache:` runs without that cache on g1t. Add an `actions/cache` step for the same effect."),
GitHub Actions on g1t, part one: reading workflows557 )),
558 _ => None,
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 const CI: &str = r#"
567name: CI
568on:
569 push:
570 branches: [main]
571 paths-ignore: ["docs/**"]
572 pull_request:
573 workflow_dispatch:
574 inputs:
575 debug:
576 type: boolean
577 default: false
578 schedule:
579 - cron: "0 3 * * *"
580concurrency:
581 group: ci-${{ github.ref }}
582 cancel-in-progress: true
583env:
584 CARGO_TERM_COLOR: always
585jobs:
586 test:
587 runs-on: ${{ matrix.os }}
588 strategy:
589 matrix:
590 os: [ubuntu-latest, windows-latest]
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24591 node: [22, 24]
GitHub Actions on g1t, part one: reading workflows592 steps:
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24593 - uses: actions/checkout@v7
594 - uses: actions/setup-node@v7
GitHub Actions on g1t, part one: reading workflows595 with:
596 node-version: ${{ matrix.node }}
597 - run: npm ci
598 - name: Test
599 run: npm test
600 deploy:
601 needs: test
602 if: github.ref == 'refs/heads/main'
603 runs-on: ubuntu-latest
604 steps:
605 - run: echo deploy
606"#;
607
608 #[test]
609 fn a_whole_workflow_reads() {
610 let workflow = parse(CI).unwrap();
611 assert_eq!(workflow.name.as_deref(), Some("CI"));
612 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
613 let push = workflow.trigger("push").unwrap();
614 assert!(push.branches.allows("main"));
615 assert!(!push.branches.allows("dev"));
616 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
617 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
618 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
619 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
620 assert_eq!(workflow.jobs.len(), 2);
621 assert_eq!(workflow.jobs[1].needs, ["test"]);
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24622 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7");
GitHub Actions on g1t, part one: reading workflows623 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
624 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
625 assert_eq!(workflow.job_order(), ["test", "deploy"]);
626 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
627 }
628
629 #[test]
630 fn short_forms_of_on() {
631 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
632 assert_eq!(one.triggers[0].event, "push");
633 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
634 assert_eq!(list.triggers.len(), 2);
635 let pr = list.trigger("pull_request").unwrap();
636 assert!(pr.wants_type(Some("opened")));
637 assert!(pr.wants_type(Some("synchronize")));
638 assert!(!pr.wants_type(Some("closed")));
A repository has its own sidebar, as settings do639 assert!(pr.wants_type(Some("ready_for_review")));
GitHub Actions on g1t, part one: reading workflows640 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
641 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
642 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
643 }
644
645 #[test]
646 fn notes_say_what_runs_differently() {
647 let workflow = parse(
Actions: workflow notes say what the cache and artifacts do now648 "on: [push, release]\njobs:\n win:\n runs-on: windows-latest\n services:\n db: { image: postgres }\n steps:\n - uses: actions/cache@v6\n - uses: actions/setup-node@v7\n with: { cache: npm }\n - uses: docker://alpine\n - run: dir\n shell: pwsh",
GitHub Actions on g1t, part one: reading workflows649 )
650 .unwrap();
651 let unsupported: Vec<&str> =
652 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
653 assert!(unsupported.iter().any(|m| m.contains("`release`")));
654 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
Merge branch 'main' into actions-toolkit-oidc-artifacts655 assert!(!unsupported.iter().any(|m| m.contains("services")));
656 assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
657 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost")));
658 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
GitHub Actions on g1t, part one: reading workflows659 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
Actions: workflow notes say what the cache and artifacts do now660 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
661 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node")));
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily662 assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry")));
663 }
664
665 #[test]
666 fn self_hosted_jobs_may_run_on_any_os() {
667 let workflow = parse(
668 "on: push
669jobs:
670 win:
671 runs-on: [self-hosted, windows]
672 steps:
673 - run: dir
674 shell: pwsh
675 mac:
676 runs-on: { group: Macs, labels: [macos] }
677 steps: [{ run: 'true' }]",
678 )
679 .unwrap();
680 assert!(!workflow.notes.iter().any(|n| n.severity == Severity::Unsupported), "{:?}", workflow.notes);
681 let routed: Vec<&str> = workflow.notes.iter().filter(|n| n.message.starts_with("`self-hosted`")).map(|n| n.message.as_str()).collect();
682 assert_eq!(routed.len(), 2);
683 assert!(routed.iter().all(|m| m.contains("self-hosted runners") && !m.contains("Linux")));
GitHub Actions on g1t, part one: reading workflows684 }
685
686 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'687 fn permissions_are_read_at_both_levels() {
688 use crate::permissions::{Access, TokenDefault};
689 let workflow = parse(
690 "on: push
691permissions:
692 contents: read
693 pull-requests: write
694jobs:
695 plain:
696 runs-on: ubuntu-latest
697 steps: [{ run: 'true' }]
698 release:
699 runs-on: ubuntu-latest
700 permissions:
701 contents: write
702 steps: [{ run: 'true' }]
703 quiet:
704 runs-on: ubuntu-latest
705 permissions: {}
706 steps: [{ run: 'true' }]",
707 )
708 .unwrap();
709 let plain = workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted);
710 assert_eq!(plain.get("pull-requests"), Access::Write);
711 assert_eq!(plain.get("contents"), Access::Read);
712 // A job's own permissions replace the workflow's whole.
713 let release = workflow.jobs[1].permissions(&workflow, TokenDefault::Permissive);
714 assert_eq!(release.get("contents"), Access::Write);
715 assert_eq!(release.get("pull-requests"), Access::None);
716 assert_eq!(workflow.jobs[2].permissions(&workflow, TokenDefault::Permissive).scopes(), ["repo:read"]);
717 // Without any, the repository's default.
718 let bare = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
719 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("contents"), Access::Read);
720 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None);
721 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write);
722 // What reads but grants nothing is said.
723 let odd = parse("on: push\npermissions: { id-token: write, wiki: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
724 assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued");
725 assert!(odd.notes.iter().any(|n| n.message.contains("`permissions.wiki`")));
726 assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all"));
727 assert!(
728 parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]")
729 .unwrap_err()
730 .contains("Job `a`")
731 );
732 }
733
734 #[test]
735 fn a_job_has_its_own_concurrency() {
736 let workflow = parse(
737 "on: push
738jobs:
739 deploy:
740 runs-on: ubuntu-latest
741 concurrency:
742 group: deploy-${{ github.ref }}
743 cancel-in-progress: true
744 steps: [{ run: 'true' }]
745 named:
746 runs-on: ubuntu-latest
747 concurrency: just-one
748 steps: [{ run: 'true' }]",
749 )
750 .unwrap();
751 let deploy = workflow.jobs[0].concurrency.as_ref().unwrap();
752 assert_eq!(deploy.group, "deploy-${{ github.ref }}");
753 assert_eq!(deploy.cancel_in_progress, Value::Bool(true));
754 assert_eq!(workflow.jobs[1].concurrency.as_ref().unwrap().group, "just-one");
755 assert!(workflow.concurrency.is_none());
756 }
757
758 #[test]
759 fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() {
760 let workflow = parse("on: [create, delete, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
761 let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
762 assert_eq!(unsupported.len(), 1, "{unsupported:?}");
763 assert!(unsupported[0].contains("`delete`"));
764 let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap();
765 assert!(target.message.contains("default branch"));
766 assert!(!target.message.contains("on the pull request's head"));
767 }
768
769 #[test]
GitHub Actions on g1t, part one: reading workflows770 fn mistakes_are_explained() {
771 let problem = |yaml: &str| parse(yaml).unwrap_err();
772 assert!(problem("jobs: {}").contains("`on` is missing"));
773 assert!(problem("on: push").contains("`jobs` is missing"));
774 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
775 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
776 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
777 assert!(
778 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 }]")
779 .contains("circle")
780 );
781 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
782 assert!(problem(": : :").contains("not valid YAML"));
783 }
784}

This file's history is long; its oldest lines are credited to the oldest commit read.