Skip to content
794 linesCodeBlameRaw

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",
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts31 "release",
32 "deployment",
33 "deployment_status",
GitHub Actions on g1t, part one: reading workflows34];
35
Merge branch 'worktree-agent-a3abfcce648e87dca'36/// Events GitHub has that g1t knows of but never sends: a workflow on one
37/// of them is told so, rather than waiting for a run that never comes.
38pub const UNSENT_EVENTS: &[(&str, &str)] = &[(
39 "delete",
40 "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.",
41)];
42
GitHub Actions on g1t, part one: reading workflows43/// The `types` each event has when a workflow gives none, as on GitHub.
44pub fn default_types(event: &str) -> &'static [&'static str] {
45 match event {
46 "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"],
47 "merge_group" => &["checks_requested"],
48 _ => &[],
49 }
50}
51
52/// How much a note matters.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum Severity {
56 /// Runs, slightly differently.
57 Info,
58 /// Runs, but something in it does nothing or may not work.
59 Warning,
60 /// Does not run on g1t.
61 Unsupported,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Note {
66 pub severity: Severity,
67 /// The job, if the note is about one.
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub job: Option<String>,
70 pub message: String,
71}
72
73/// One event a workflow is started by, with its filters.
74#[derive(Clone, Debug, Default, PartialEq, Eq)]
75pub struct Trigger {
76 pub event: String,
77 /// Activity types; empty means the event's defaults (or all).
78 pub types: Vec<String>,
79 pub branches: Filter,
80 pub tags: Filter,
81 pub paths: Filter,
82 /// For `schedule`.
83 pub crons: Vec<String>,
84 /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
85 pub inputs: Map<String, Value>,
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 2486 /// For `workflow_run`: the names of the workflows it follows.
87 pub workflows: Vec<String>,
GitHub Actions on g1t, part one: reading workflows88}
89
90impl Trigger {
91 /// Whether an activity type starts it.
92 pub fn wants_type(&self, action: Option<&str>) -> bool {
93 let Some(action) = action else { return true };
94 if self.types.is_empty() {
A repository has its own sidebar, as settings do95 // A g1t agent's pull request has no code until it is marked
96 // ready, so that is when its default runs start, as `opened`
97 // would on GitHub.
98 if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" {
99 return true;
100 }
GitHub Actions on g1t, part one: reading workflows101 let defaults = default_types(&self.event);
102 return defaults.is_empty() || defaults.contains(&action);
103 }
104 self.types.iter().any(|t| t == action)
105 }
106}
107
108#[derive(Clone, Debug, PartialEq)]
109pub struct Step {
110 pub id: Option<String>,
111 pub name: Option<String>,
112 pub condition: Option<String>,
113 pub uses: Option<String>,
114 pub run: Option<String>,
115 /// The whole step as written, for the sandbox.
116 pub raw: Value,
117}
118
119impl Step {
120 /// How the step is shown when it has no name.
121 pub fn title(&self) -> String {
122 if let Some(name) = &self.name {
123 return name.clone();
124 }
125 if let Some(uses) = &self.uses {
126 return format!("Run {uses}");
127 }
128 let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default();
129 format!("Run {}", first.trim())
130 }
131}
132
133#[derive(Clone, Debug, PartialEq)]
134pub struct Job {
135 /// Its key under `jobs:`.
136 pub id: String,
137 pub name: Option<String>,
138 pub needs: Vec<String>,
139 pub condition: Option<String>,
140 pub runs_on: Value,
141 /// `strategy.matrix`, as written (it may be an expression).
142 pub matrix: Option<Value>,
143 pub fail_fast: bool,
144 pub max_parallel: Option<u32>,
145 /// A reusable workflow it calls (`uses:` on a job).
146 pub uses: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'147 /// Its own `permissions`, which replace the workflow's.
148 pub permissions: Option<Permissions>,
149 /// Its own `concurrency`: at most one job of its group runs at a time.
150 pub concurrency: Option<Concurrency>,
GitHub Actions on g1t, part one: reading workflows151 pub steps: Vec<Step>,
152 /// The whole job as written, for the sandbox.
153 pub raw: Value,
154}
155
Merge branch 'worktree-agent-a3abfcce648e87dca'156impl Job {
157 /// What its token may do: its own `permissions`, else its workflow's,
158 /// else `default` (the repository's choice).
159 pub fn permissions(&self, workflow: &Workflow, default: permissions::TokenDefault) -> Permissions {
160 self.permissions
161 .clone()
162 .or_else(|| workflow.permissions.clone())
163 .unwrap_or_else(|| Permissions::default_for(default))
164 }
165}
166
GitHub Actions on g1t, part one: reading workflows167#[derive(Clone, Debug, PartialEq)]
168pub struct Workflow {
169 pub name: Option<String>,
170 pub run_name: Option<String>,
171 pub triggers: Vec<Trigger>,
172 pub env: Map<String, Value>,
173 pub concurrency: Option<Concurrency>,
Merge branch 'worktree-agent-a3abfcce648e87dca'174 /// Its top-level `permissions`, for every job that writes none.
175 pub permissions: Option<Permissions>,
GitHub Actions on g1t, part one: reading workflows176 pub jobs: Vec<Job>,
177 pub notes: Vec<Note>,
178 /// The whole workflow as written.
179 pub raw: Value,
180}
181
182#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct Concurrency {
184 /// May hold an expression.
185 pub group: String,
186 pub cancel_in_progress: Value,
187}
188
189impl Workflow {
190 pub fn trigger(&self, event: &str) -> Option<&Trigger> {
191 self.triggers.iter().find(|trigger| trigger.event == event)
192 }
193
194 /// The name shown for it: its `name`, or its file's path.
195 pub fn display_name(&self, path: &str) -> String {
196 self.name.clone().unwrap_or_else(|| path.to_owned())
197 }
198
199 /// The job ids in an order where each comes after the jobs it needs.
200 pub fn job_order(&self) -> Vec<&str> {
201 let mut ordered: Vec<&str> = Vec::new();
202 while ordered.len() < self.jobs.len() {
203 let before = ordered.len();
204 for job in &self.jobs {
205 if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) {
206 ordered.push(&job.id);
207 }
208 }
209 if ordered.len() == before {
210 break;
211 }
212 }
213 ordered
214 }
215}
216
217/// YAML to JSON, keeping the order of keys. Keys that are not strings
218/// (`on: true` in YAML 1.1, numbers) become their text.
219pub fn yaml_to_json(value: &serde_yaml::Value) -> Value {
220 match value {
221 serde_yaml::Value::Null => Value::Null,
222 serde_yaml::Value::Bool(flag) => Value::Bool(*flag),
223 serde_yaml::Value::Number(number) => {
224 if let Some(n) = number.as_i64() {
225 Value::from(n)
226 } else if let Some(n) = number.as_u64() {
227 Value::from(n)
228 } else {
229 number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number)
230 }
231 }
232 serde_yaml::Value::String(text) => Value::String(text.clone()),
233 serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()),
234 serde_yaml::Value::Mapping(map) => {
235 let mut out = Map::new();
236 for (key, value) in map {
237 let key = match key {
238 serde_yaml::Value::String(text) => text.clone(),
239 serde_yaml::Value::Bool(flag) => flag.to_string(),
240 serde_yaml::Value::Number(number) => number.to_string(),
241 _ => continue,
242 };
243 out.insert(key, yaml_to_json(value));
244 }
245 Value::Object(out)
246 }
247 serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value),
248 }
249}
250
251fn texts(value: Option<&Value>) -> Vec<String> {
252 match value {
253 Some(Value::String(text)) => vec![text.clone()],
254 Some(Value::Array(items)) => items
255 .iter()
256 .filter_map(|item| match item {
257 Value::String(text) => Some(text.clone()),
258 Value::Number(n) => Some(n.to_string()),
259 _ => None,
260 })
261 .collect(),
262 _ => Vec::new(),
263 }
264}
265
266fn text(value: Option<&Value>) -> Option<String> {
267 match value? {
268 Value::String(text) => Some(text.clone()),
269 Value::Number(n) => Some(n.to_string()),
270 Value::Bool(flag) => Some(flag.to_string()),
271 _ => None,
272 }
273}
274
275fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter {
276 let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value))));
277 Filter { only: list(only), ignore: list(ignore) }
278}
279
280fn trigger(event: &str, spec: &Value) -> Trigger {
281 let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() };
282 match spec {
283 Value::Object(spec) => {
284 trigger.types = texts(spec.get("types"));
285 trigger.branches = filter(spec, "branches", "branches-ignore");
286 trigger.tags = filter(spec, "tags", "tags-ignore");
287 trigger.paths = filter(spec, "paths", "paths-ignore");
288 if let Some(Value::Object(inputs)) = spec.get("inputs") {
289 trigger.inputs = inputs.clone();
290 }
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24291 trigger.workflows = texts(spec.get("workflows"));
GitHub Actions on g1t, part one: reading workflows292 }
293 Value::Array(entries) if event == "schedule" => {
294 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
295 }
296 _ => {}
297 }
298 trigger
299}
300
301/// Reads a workflow. `Err` is what is wrong with the file, for the person
302/// who wrote it; what reads but runs differently is in `notes`.
303pub fn parse(source: &str) -> Result<Workflow, String> {
304 let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?;
305 let raw = yaml_to_json(&yaml);
306 let Value::Object(root) = &raw else {
307 return Err("A workflow is a mapping with `on` and `jobs`.".to_owned());
308 };
309 let mut notes = Vec::new();
310 let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message });
311
312 // `on`, in any of its three shapes. YAML 1.1 readers turn `on` into
313 // `true`; this reader keeps it, and accepts both.
314 let on = root.get("on").or_else(|| root.get("true")).ok_or("`on` is missing: say which events start the workflow.")?;
315 let mut triggers = Vec::new();
316 match on {
317 Value::String(event) => triggers.push(trigger(event, &Value::Null)),
318 Value::Array(events) => {
319 for event in events {
320 let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) };
321 triggers.push(trigger(event, &Value::Null));
322 }
323 }
324 Value::Object(events) => {
325 for (event, spec) in events {
326 triggers.push(trigger(event, spec));
327 }
328 }
329 _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()),
330 }
331 for trigger in &triggers {
Merge branch 'worktree-agent-a3abfcce648e87dca'332 if let Some((_, why)) = UNSENT_EVENTS.iter().find(|(event, _)| *event == trigger.event) {
333 note(Severity::Unsupported, None, (*why).to_owned());
334 } else if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) {
GitHub Actions on g1t, part one: reading workflows335 note(
336 Severity::Unsupported,
337 None,
338 format!("g1t has no `{}` event, so that trigger never starts it.", trigger.event),
339 );
340 }
341 if trigger.event == "pull_request_target" {
342 note(
343 Severity::Info,
344 None,
Merge branch 'worktree-agent-a3abfcce648e87dca'345 "`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 workflows346 );
347 }
348 if trigger.event == "workflow_call" && triggers.len() == 1 {
349 note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned());
350 }
351 }
352
353 let env = match root.get("env") {
354 Some(Value::Object(env)) => env.clone(),
355 _ => Map::new(),
356 };
Merge branch 'worktree-agent-a3abfcce648e87dca'357 let concurrency = concurrency_of(root.get("concurrency"));
358 let permissions = match root.get("permissions") {
359 None => None,
360 Some(value) => {
361 let (permissions, unknown) = permissions::parse(value)?;
362 permission_notes(&unknown, None, &mut note);
363 Some(permissions)
364 }
GitHub Actions on g1t, part one: reading workflows365 };
366
367 let Some(Value::Object(job_specs)) = root.get("jobs") else {
368 return Err("`jobs` is missing: a workflow needs at least one job.".to_owned());
369 };
370 if job_specs.is_empty() {
371 return Err("`jobs` is empty: a workflow needs at least one job.".to_owned());
372 }
373 let mut jobs = Vec::new();
374 for (id, spec) in job_specs {
375 let Value::Object(spec) = spec else {
376 return Err(format!("Job `{id}` is a mapping."));
377 };
378 let uses = text(spec.get("uses"));
379 let steps_raw = match spec.get("steps") {
380 Some(Value::Array(steps)) => steps.clone(),
381 None if uses.is_some() => Vec::new(),
382 None => return Err(format!("Job `{id}` has no `steps`.")),
383 Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")),
384 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily385 let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null);
386 let labels: Vec<String> =
387 texts(Some(&runs_on)).into_iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default()).collect();
388 // `self-hosted`, or a runner group, sends the job to the workspace's
389 // own runners, which may be Linux, macOS or Windows.
390 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 workflows391 let mut steps = Vec::new();
392 for (index, step) in steps_raw.iter().enumerate() {
393 let Value::Object(fields) = step else {
394 return Err(format!("Job `{id}`, step {}: a step is a mapping.", index + 1));
395 };
396 let step = Step {
397 id: text(fields.get("id")),
398 name: text(fields.get("name")),
399 condition: text(fields.get("if")),
400 uses: text(fields.get("uses")),
401 run: text(fields.get("run")),
402 raw: step.clone(),
403 };
404 match (&step.uses, &step.run) {
405 (Some(_), Some(_)) => return Err(format!("Job `{id}`, step {}: a step has `uses` or `run`, not both.", index + 1)),
406 (None, None) => return Err(format!("Job `{id}`, step {}: a step needs `uses` or `run`.", index + 1)),
407 _ => {}
408 }
409 if let Some(uses) = &step.uses
Actions: workflow notes say what the cache and artifacts do now410 && 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 workflows411 {
412 note(severity, Some(id), message);
413 }
414 if let Some(shell) = text(fields.get("shell"))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily415 && !self_hosted
GitHub Actions on g1t, part one: reading workflows416 && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd")
417 {
418 note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have."));
419 }
420 steps.push(step);
421 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily422 if self_hosted {
423 note(
424 Severity::Info,
425 Some(id),
426 "`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(),
427 );
428 } else {
429 for label in &labels {
430 let lower = label.to_ascii_lowercase();
431 if lower.contains("windows") || lower.contains("macos") {
432 note(
433 Severity::Unsupported,
434 Some(id),
435 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, ...]`."),
436 );
437 }
GitHub Actions on g1t, part one: reading workflows438 }
439 }
440 if spec.contains_key("services") {
Merge branch 'main' into actions-toolkit-oidc-artifacts441 note(
442 Severity::Info,
443 Some(id),
444 "`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(),
445 );
GitHub Actions on g1t, part one: reading workflows446 }
447 if spec.contains_key("container") {
Merge branch 'main' into actions-toolkit-oidc-artifacts448 note(
449 Severity::Info,
450 Some(id),
451 "`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(),
452 );
GitHub Actions on g1t, part one: reading workflows453 }
454 if spec.contains_key("environment") {
Merge branch 'worktree-agent-a3abfcce648e87dca'455 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 workflows456 }
Merge branch 'worktree-agent-a3abfcce648e87dca'457 let job_permissions = match spec.get("permissions") {
458 None => None,
459 Some(value) => {
460 let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?;
461 permission_notes(&unknown, Some(id), &mut note);
462 Some(permissions)
463 }
464 };
GitHub Actions on g1t, part one: reading workflows465 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
466 Some(Value::Object(strategy)) => (
467 strategy.get("matrix").cloned(),
468 strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true),
469 strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32),
470 ),
471 _ => (None, true, None),
472 };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts473 if let Some(called) = uses.as_deref().filter(|uses| !uses.starts_with("./")) {
Actions: reusable workflows in the repository474 note(
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts475 Severity::Info,
Actions: reusable workflows in the repository476 Some(id),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts477 format!(
478 "`{called}` is read from that repository on g1t when it is there and this repository may use it (a private one allows it under Settings, Actions, Access), and otherwise from a public repository on GitHub."
479 ),
Actions: reusable workflows in the repository480 );
GitHub Actions on g1t, part one: reading workflows481 }
482 jobs.push(Job {
483 id: id.clone(),
484 name: text(spec.get("name")),
485 needs: texts(spec.get("needs")),
486 condition: text(spec.get("if")),
487 runs_on,
488 matrix,
489 fail_fast,
490 max_parallel,
491 uses,
Merge branch 'worktree-agent-a3abfcce648e87dca'492 permissions: job_permissions,
493 concurrency: concurrency_of(spec.get("concurrency")),
GitHub Actions on g1t, part one: reading workflows494 steps,
495 raw: Value::Object(spec.clone()),
496 });
497 }
498 for job in &jobs {
499 for need in &job.needs {
500 if !jobs.iter().any(|other| &other.id == need) {
501 return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id));
502 }
503 }
504 }
505 let workflow = Workflow {
506 name: text(root.get("name")),
507 run_name: text(root.get("run-name")),
508 triggers,
509 env,
510 concurrency,
Merge branch 'worktree-agent-a3abfcce648e87dca'511 permissions,
GitHub Actions on g1t, part one: reading workflows512 jobs,
513 notes,
514 raw,
515 };
516 if workflow.job_order().len() < workflow.jobs.len() {
517 return Err("The jobs' `needs` go round in a circle.".to_owned());
518 }
519 Ok(workflow)
520}
521
Merge branch 'worktree-agent-a3abfcce648e87dca'522/// `concurrency`, as a group's name or a mapping with `group` and
523/// `cancel-in-progress`.
524fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> {
525 match value {
526 Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }),
527 Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency {
528 group,
529 cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)),
530 }),
531 _ => None,
532 }
533}
534
535/// What to say about `permissions` names the token does not have.
536fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
537 for name in unknown {
538 note(Severity::Warning, job, format!("`permissions.{name}`: the token has no permission called that, so it grants nothing."));
539 }
540}
541
GitHub Actions on g1t, part one: reading workflows542/// What to say about an action g1t runs differently, if anything.
Actions: workflow notes say what the cache and artifacts do now543/// `caches`: the step sets a `cache` input.
544fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> {
GitHub Actions on g1t, part one: reading workflows545 if uses.starts_with("docker://") {
Merge branch 'main' into actions-toolkit-oidc-artifacts546 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 workflows547 }
548 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
549 match name.as_str() {
550 "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())),
551 "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some((
Actions: workflow notes say what the cache and artifacts do now552 Severity::Info,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily553 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 workflows554 )),
555 "actions/upload-artifact" | "actions/download-artifact" => Some((
Actions: workflow notes say what the cache and artifacts do now556 Severity::Info,
557 format!("`{name}`: g1t keeps artifacts with the run for 14 days, up to 60 MB each."),
558 )),
559 _ if caches && name.starts_with("actions/setup-") => Some((
GitHub Actions on g1t, part one: reading workflows560 Severity::Warning,
Actions: workflow notes say what the cache and artifacts do now561 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 workflows562 )),
563 _ => None,
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 const CI: &str = r#"
572name: CI
573on:
574 push:
575 branches: [main]
576 paths-ignore: ["docs/**"]
577 pull_request:
578 workflow_dispatch:
579 inputs:
580 debug:
581 type: boolean
582 default: false
583 schedule:
584 - cron: "0 3 * * *"
585concurrency:
586 group: ci-${{ github.ref }}
587 cancel-in-progress: true
588env:
589 CARGO_TERM_COLOR: always
590jobs:
591 test:
592 runs-on: ${{ matrix.os }}
593 strategy:
594 matrix:
595 os: [ubuntu-latest, windows-latest]
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24596 node: [22, 24]
GitHub Actions on g1t, part one: reading workflows597 steps:
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24598 - uses: actions/checkout@v7
599 - uses: actions/setup-node@v7
GitHub Actions on g1t, part one: reading workflows600 with:
601 node-version: ${{ matrix.node }}
602 - run: npm ci
603 - name: Test
604 run: npm test
605 deploy:
606 needs: test
607 if: github.ref == 'refs/heads/main'
608 runs-on: ubuntu-latest
609 steps:
610 - run: echo deploy
611"#;
612
613 #[test]
614 fn a_whole_workflow_reads() {
615 let workflow = parse(CI).unwrap();
616 assert_eq!(workflow.name.as_deref(), Some("CI"));
617 assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]);
618 let push = workflow.trigger("push").unwrap();
619 assert!(push.branches.allows("main"));
620 assert!(!push.branches.allows("dev"));
621 assert!(!push.paths.allows_paths(&["docs/a.md".into()]));
622 assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]);
623 assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug"));
624 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
625 assert_eq!(workflow.jobs.len(), 2);
626 assert_eq!(workflow.jobs[1].needs, ["test"]);
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24627 assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7");
GitHub Actions on g1t, part one: reading workflows628 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
629 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
630 assert_eq!(workflow.job_order(), ["test", "deploy"]);
631 assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always");
632 }
633
634 #[test]
635 fn short_forms_of_on() {
636 let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
637 assert_eq!(one.triggers[0].event, "push");
638 let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
639 assert_eq!(list.triggers.len(), 2);
640 let pr = list.trigger("pull_request").unwrap();
641 assert!(pr.wants_type(Some("opened")));
642 assert!(pr.wants_type(Some("synchronize")));
643 assert!(!pr.wants_type(Some("closed")));
A repository has its own sidebar, as settings do644 assert!(pr.wants_type(Some("ready_for_review")));
GitHub Actions on g1t, part one: reading workflows645 let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
646 assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed")));
647 assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened")));
648 }
649
650 #[test]
651 fn notes_say_what_runs_differently() {
652 let workflow = parse(
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts653 "on: [push, watch]\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 workflows654 )
655 .unwrap();
656 let unsupported: Vec<&str> =
657 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts658 assert!(unsupported.iter().any(|m| m.contains("`watch`")));
659 let released = parse("on:\n release:\n types: [published]\n deployment_status:\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
660 assert!(!released.notes.iter().any(|n| n.severity == Severity::Unsupported));
661 assert!(released.trigger("release").unwrap().wants_type(Some("published")));
662 assert!(!released.trigger("release").unwrap().wants_type(Some("created")));
663 assert!(released.trigger("deployment_status").unwrap().wants_type(Some("created")));
GitHub Actions on g1t, part one: reading workflows664 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
Merge branch 'main' into actions-toolkit-oidc-artifacts665 assert!(!unsupported.iter().any(|m| m.contains("services")));
666 assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
667 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost")));
668 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
GitHub Actions on g1t, part one: reading workflows669 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
Actions: workflow notes say what the cache and artifacts do now670 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
671 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 daily672 assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry")));
673 }
674
675 #[test]
676 fn self_hosted_jobs_may_run_on_any_os() {
677 let workflow = parse(
678 "on: push
679jobs:
680 win:
681 runs-on: [self-hosted, windows]
682 steps:
683 - run: dir
684 shell: pwsh
685 mac:
686 runs-on: { group: Macs, labels: [macos] }
687 steps: [{ run: 'true' }]",
688 )
689 .unwrap();
690 assert!(!workflow.notes.iter().any(|n| n.severity == Severity::Unsupported), "{:?}", workflow.notes);
691 let routed: Vec<&str> = workflow.notes.iter().filter(|n| n.message.starts_with("`self-hosted`")).map(|n| n.message.as_str()).collect();
692 assert_eq!(routed.len(), 2);
693 assert!(routed.iter().all(|m| m.contains("self-hosted runners") && !m.contains("Linux")));
GitHub Actions on g1t, part one: reading workflows694 }
695
696 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'697 fn permissions_are_read_at_both_levels() {
698 use crate::permissions::{Access, TokenDefault};
699 let workflow = parse(
700 "on: push
701permissions:
702 contents: read
703 pull-requests: write
704jobs:
705 plain:
706 runs-on: ubuntu-latest
707 steps: [{ run: 'true' }]
708 release:
709 runs-on: ubuntu-latest
710 permissions:
711 contents: write
712 steps: [{ run: 'true' }]
713 quiet:
714 runs-on: ubuntu-latest
715 permissions: {}
716 steps: [{ run: 'true' }]",
717 )
718 .unwrap();
719 let plain = workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted);
720 assert_eq!(plain.get("pull-requests"), Access::Write);
721 assert_eq!(plain.get("contents"), Access::Read);
722 // A job's own permissions replace the workflow's whole.
723 let release = workflow.jobs[1].permissions(&workflow, TokenDefault::Permissive);
724 assert_eq!(release.get("contents"), Access::Write);
725 assert_eq!(release.get("pull-requests"), Access::None);
726 assert_eq!(workflow.jobs[2].permissions(&workflow, TokenDefault::Permissive).scopes(), ["repo:read"]);
727 // Without any, the repository's default.
728 let bare = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
729 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("contents"), Access::Read);
730 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None);
731 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write);
732 // What reads but grants nothing is said.
733 let odd = parse("on: push\npermissions: { id-token: write, wiki: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
734 assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued");
735 assert!(odd.notes.iter().any(|n| n.message.contains("`permissions.wiki`")));
736 assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all"));
737 assert!(
738 parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]")
739 .unwrap_err()
740 .contains("Job `a`")
741 );
742 }
743
744 #[test]
745 fn a_job_has_its_own_concurrency() {
746 let workflow = parse(
747 "on: push
748jobs:
749 deploy:
750 runs-on: ubuntu-latest
751 concurrency:
752 group: deploy-${{ github.ref }}
753 cancel-in-progress: true
754 steps: [{ run: 'true' }]
755 named:
756 runs-on: ubuntu-latest
757 concurrency: just-one
758 steps: [{ run: 'true' }]",
759 )
760 .unwrap();
761 let deploy = workflow.jobs[0].concurrency.as_ref().unwrap();
762 assert_eq!(deploy.group, "deploy-${{ github.ref }}");
763 assert_eq!(deploy.cancel_in_progress, Value::Bool(true));
764 assert_eq!(workflow.jobs[1].concurrency.as_ref().unwrap().group, "just-one");
765 assert!(workflow.concurrency.is_none());
766 }
767
768 #[test]
769 fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() {
770 let workflow = parse("on: [create, delete, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
771 let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
772 assert_eq!(unsupported.len(), 1, "{unsupported:?}");
773 assert!(unsupported[0].contains("`delete`"));
774 let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap();
775 assert!(target.message.contains("default branch"));
776 assert!(!target.message.contains("on the pull request's head"));
777 }
778
779 #[test]
GitHub Actions on g1t, part one: reading workflows780 fn mistakes_are_explained() {
781 let problem = |yaml: &str| parse(yaml).unwrap_err();
782 assert!(problem("jobs: {}").contains("`on` is missing"));
783 assert!(problem("on: push").contains("`jobs` is missing"));
784 assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`"));
785 assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`"));
786 assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that"));
787 assert!(
788 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 }]")
789 .contains("circle")
790 );
791 assert!(problem("on: push\njobs: [1]").contains("`jobs`"));
792 assert!(problem(": : :").contains("not valid YAML"));
793 }
794}

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