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.
| The statement is a month at a time, a line per kind of charge | 1 | //! 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 | ||
| 5 | use serde::{Deserialize, Serialize}; | |
| 6 | use serde_json::{Map, Value}; | |
| 7 | ||
| 8 | use crate::filter::{Filter, Patterns}; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 9 | use crate::permissions::{self, Permissions}; |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 10 | use crate::syntax; |
| The statement is a month at a time, a line per kind of charge | 11 | |
| 12 | /// Where workflows live: GitHub's `.github/workflows`, under g1t's own | |
| 13 | /// folder, so moving a repository to g1t is renaming `.github` to `.g1t`. | |
| Merge branch 'mirroring' into artifacts-mode | 14 | /// g1t reads `.github` only for a mirror of a GitHub repository in CI |
| 15 | /// failover or taken over (services/actions/src/mirrored.rs); otherwise it | |
| 16 | /// stays GitHub's. | |
| The statement is a month at a time, a line per kind of charge | 17 | pub const FOLDER: &str = ".g1t/workflows"; |
| 18 | ||
| 19 | /// The events a workflow can name that g1t starts runs for. | |
| 20 | pub const SUPPORTED_EVENTS: &[&str] = &[ | |
| 21 | "push", | |
| 22 | "pull_request", | |
| 23 | "pull_request_target", | |
| 24 | "pull_request_review", | |
| 25 | "issues", | |
| 26 | "issue_comment", | |
| 27 | "schedule", | |
| 28 | "workflow_dispatch", | |
| 29 | "repository_dispatch", | |
| 30 | "workflow_call", | |
| 31 | "workflow_run", | |
| 32 | "merge_group", | |
| 33 | "create", | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 34 | "delete", |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 35 | "release", |
| 36 | "deployment", | |
| 37 | "deployment_status", | |
| The statement is a month at a time, a line per kind of charge | 38 | ]; |
| 39 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 40 | /// Events GitHub has that g1t knows of but never sends: a workflow on one |
| 41 | /// of them is told so, rather than waiting for a run that never comes. | |
| 42 | pub const UNSENT_EVENTS: &[(&str, &str)] = &[( | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 43 | "label", |
| 44 | "g1t does not start runs when a label is created, edited or deleted yet, so the `label` trigger never starts it. Putting a label on an issue or a pull request starts `issues` and `pull_request` workflows with the `labeled` type.", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 45 | )]; |
| 46 | ||
| The statement is a month at a time, a line per kind of charge | 47 | /// The `types` each event has when a workflow gives none, as on GitHub. |
| 48 | pub fn default_types(event: &str) -> &'static [&'static str] { | |
| 49 | match event { | |
| 50 | "pull_request" | "pull_request_target" => &["opened", "synchronize", "reopened"], | |
| 51 | "merge_group" => &["checks_requested"], | |
| 52 | _ => &[], | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /// How much a note matters. | |
| 57 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 58 | #[serde(rename_all = "snake_case")] | |
| 59 | pub enum Severity { | |
| 60 | /// Runs, slightly differently. | |
| 61 | Info, | |
| 62 | /// Runs, but something in it does nothing or may not work. | |
| 63 | Warning, | |
| 64 | /// Does not run on g1t. | |
| 65 | Unsupported, | |
| 66 | } | |
| 67 | ||
| 68 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 69 | pub struct Note { | |
| 70 | pub severity: Severity, | |
| 71 | /// The job, if the note is about one. | |
| 72 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 73 | pub job: Option<String>, | |
| 74 | pub message: String, | |
| 75 | } | |
| 76 | ||
| 77 | /// One event a workflow is started by, with its filters. | |
| 78 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 79 | pub struct Trigger { | |
| 80 | pub event: String, | |
| 81 | /// Activity types; empty means the event's defaults (or all). | |
| 82 | pub types: Vec<String>, | |
| 83 | pub branches: Filter, | |
| 84 | pub tags: Filter, | |
| 85 | pub paths: Filter, | |
| 86 | /// For `schedule`. | |
| 87 | pub crons: Vec<String>, | |
| 88 | /// For `workflow_dispatch` and `workflow_call`: the inputs, as written. | |
| 89 | pub inputs: Map<String, Value>, | |
| 90 | /// For `workflow_run`: the names of the workflows it follows. | |
| 91 | pub workflows: Vec<String>, | |
| 92 | } | |
| 93 | ||
| 94 | impl Trigger { | |
| 95 | /// Whether an activity type starts it. | |
| 96 | pub fn wants_type(&self, action: Option<&str>) -> bool { | |
| 97 | let Some(action) = action else { return true }; | |
| 98 | if self.types.is_empty() { | |
| 99 | // A g1t agent's pull request has no code until it is marked | |
| 100 | // ready, so that is when its default runs start, as `opened` | |
| 101 | // would on GitHub. | |
| 102 | if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" { | |
| 103 | return true; | |
| 104 | } | |
| 105 | let defaults = default_types(&self.event); | |
| 106 | return defaults.is_empty() || defaults.contains(&action); | |
| 107 | } | |
| 108 | self.types.iter().any(|t| t == action) | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | #[derive(Clone, Debug, PartialEq)] | |
| 113 | pub struct Step { | |
| 114 | pub id: Option<String>, | |
| 115 | pub name: Option<String>, | |
| 116 | pub condition: Option<String>, | |
| 117 | pub uses: Option<String>, | |
| 118 | pub run: Option<String>, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 119 | /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys. |
| 120 | pub g1t: Option<Value>, | |
| The statement is a month at a time, a line per kind of charge | 121 | /// The whole step as written, for the sandbox. |
| 122 | pub raw: Value, | |
| 123 | } | |
| 124 | ||
| 125 | impl Step { | |
| 126 | /// How the step is shown when it has no name. | |
| 127 | pub fn title(&self) -> String { | |
| 128 | if let Some(name) = &self.name { | |
| 129 | return name.clone(); | |
| 130 | } | |
| 131 | if let Some(uses) = &self.uses { | |
| 132 | return format!("Run {uses}"); | |
| 133 | } | |
| 134 | let first = self.run.as_deref().unwrap_or_default().lines().find(|line| !line.trim().is_empty()).unwrap_or_default(); | |
| 135 | format!("Run {}", first.trim()) | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | #[derive(Clone, Debug, PartialEq)] | |
| 140 | pub struct Job { | |
| 141 | /// Its key under `jobs:`. | |
| 142 | pub id: String, | |
| 143 | pub name: Option<String>, | |
| 144 | pub needs: Vec<String>, | |
| 145 | pub condition: Option<String>, | |
| 146 | pub runs_on: Value, | |
| 147 | /// `strategy.matrix`, as written (it may be an expression). | |
| 148 | pub matrix: Option<Value>, | |
| 149 | pub fail_fast: bool, | |
| 150 | pub max_parallel: Option<u32>, | |
| 151 | /// A reusable workflow it calls (`uses:` on a job). | |
| 152 | pub uses: Option<String>, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 153 | /// Its own `permissions`, which replace the workflow's. |
| 154 | pub permissions: Option<Permissions>, | |
| 155 | /// Its own `concurrency`: at most one job of its group runs at a time. | |
| 156 | pub concurrency: Option<Concurrency>, | |
| The statement is a month at a time, a line per kind of charge | 157 | pub steps: Vec<Step>, |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 158 | /// Its `g1t` settings, as written: g1t's own, beside GitHub's keys. |
| 159 | pub g1t: Option<Value>, | |
| The statement is a month at a time, a line per kind of charge | 160 | /// The whole job as written, for the sandbox. |
| 161 | pub raw: Value, | |
| 162 | } | |
| 163 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 164 | impl Job { |
| 165 | /// What its token may do: its own `permissions`, else its workflow's, | |
| 166 | /// else `default` (the repository's choice). | |
| 167 | pub fn permissions(&self, workflow: &Workflow, default: permissions::TokenDefault) -> Permissions { | |
| 168 | self.permissions | |
| 169 | .clone() | |
| 170 | .or_else(|| workflow.permissions.clone()) | |
| 171 | .unwrap_or_else(|| Permissions::default_for(default)) | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| The statement is a month at a time, a line per kind of charge | 175 | #[derive(Clone, Debug, PartialEq)] |
| 176 | pub struct Workflow { | |
| 177 | pub name: Option<String>, | |
| 178 | pub run_name: Option<String>, | |
| 179 | pub triggers: Vec<Trigger>, | |
| 180 | pub env: Map<String, Value>, | |
| 181 | pub concurrency: Option<Concurrency>, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 182 | /// Its top-level `permissions`, for every job that writes none. |
| 183 | pub permissions: Option<Permissions>, | |
| The statement is a month at a time, a line per kind of charge | 184 | pub jobs: Vec<Job>, |
| 185 | pub notes: Vec<Note>, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 186 | /// Its top-level `g1t` settings, as written: g1t's own, beside |
| 187 | /// GitHub's keys. | |
| 188 | pub g1t: Option<Value>, | |
| The statement is a month at a time, a line per kind of charge | 189 | /// The whole workflow as written. |
| 190 | pub raw: Value, | |
| 191 | } | |
| 192 | ||
| 193 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 194 | pub struct Concurrency { | |
| 195 | /// May hold an expression. | |
| 196 | pub group: String, | |
| 197 | pub cancel_in_progress: Value, | |
| 198 | } | |
| 199 | ||
| 200 | impl Workflow { | |
| 201 | pub fn trigger(&self, event: &str) -> Option<&Trigger> { | |
| 202 | self.triggers.iter().find(|trigger| trigger.event == event) | |
| 203 | } | |
| 204 | ||
| 205 | /// The name shown for it: its `name`, or its file's path. | |
| 206 | pub fn display_name(&self, path: &str) -> String { | |
| 207 | self.name.clone().unwrap_or_else(|| path.to_owned()) | |
| 208 | } | |
| 209 | ||
| 210 | /// The job ids in an order where each comes after the jobs it needs. | |
| 211 | pub fn job_order(&self) -> Vec<&str> { | |
| 212 | let mut ordered: Vec<&str> = Vec::new(); | |
| 213 | while ordered.len() < self.jobs.len() { | |
| 214 | let before = ordered.len(); | |
| 215 | for job in &self.jobs { | |
| 216 | if !ordered.contains(&job.id.as_str()) && job.needs.iter().all(|need| ordered.contains(&need.as_str())) { | |
| 217 | ordered.push(&job.id); | |
| 218 | } | |
| 219 | } | |
| 220 | if ordered.len() == before { | |
| 221 | break; | |
| 222 | } | |
| 223 | } | |
| 224 | ordered | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | /// YAML to JSON, keeping the order of keys. Keys that are not strings | |
| 229 | /// (`on: true` in YAML 1.1, numbers) become their text. | |
| 230 | pub fn yaml_to_json(value: &serde_yaml::Value) -> Value { | |
| 231 | match value { | |
| 232 | serde_yaml::Value::Null => Value::Null, | |
| 233 | serde_yaml::Value::Bool(flag) => Value::Bool(*flag), | |
| 234 | serde_yaml::Value::Number(number) => { | |
| 235 | if let Some(n) = number.as_i64() { | |
| 236 | Value::from(n) | |
| 237 | } else if let Some(n) = number.as_u64() { | |
| 238 | Value::from(n) | |
| 239 | } else { | |
| 240 | number.as_f64().and_then(serde_json::Number::from_f64).map_or(Value::Null, Value::Number) | |
| 241 | } | |
| 242 | } | |
| 243 | serde_yaml::Value::String(text) => Value::String(text.clone()), | |
| 244 | serde_yaml::Value::Sequence(items) => Value::Array(items.iter().map(yaml_to_json).collect()), | |
| 245 | serde_yaml::Value::Mapping(map) => { | |
| 246 | let mut out = Map::new(); | |
| 247 | for (key, value) in map { | |
| 248 | let key = match key { | |
| 249 | serde_yaml::Value::String(text) => text.clone(), | |
| 250 | serde_yaml::Value::Bool(flag) => flag.to_string(), | |
| 251 | serde_yaml::Value::Number(number) => number.to_string(), | |
| 252 | _ => continue, | |
| 253 | }; | |
| 254 | out.insert(key, yaml_to_json(value)); | |
| 255 | } | |
| 256 | Value::Object(out) | |
| 257 | } | |
| 258 | serde_yaml::Value::Tagged(tagged) => yaml_to_json(&tagged.value), | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 262 | fn texts(value: Option<&Value>) -> Vec<String> { | |
| 263 | match value { | |
| 264 | Some(Value::String(text)) => vec![text.clone()], | |
| 265 | Some(Value::Array(items)) => items | |
| 266 | .iter() | |
| 267 | .filter_map(|item| match item { | |
| 268 | Value::String(text) => Some(text.clone()), | |
| 269 | Value::Number(n) => Some(n.to_string()), | |
| 270 | _ => None, | |
| 271 | }) | |
| 272 | .collect(), | |
| 273 | _ => Vec::new(), | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | fn text(value: Option<&Value>) -> Option<String> { | |
| 278 | match value? { | |
| 279 | Value::String(text) => Some(text.clone()), | |
| 280 | Value::Number(n) => Some(n.to_string()), | |
| 281 | Value::Bool(flag) => Some(flag.to_string()), | |
| 282 | _ => None, | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | fn filter(spec: &Map<String, Value>, only: &str, ignore: &str) -> Filter { | |
| 287 | let list = |key: &str| spec.get(key).map(|value| Patterns::new(&texts(Some(value)))); | |
| 288 | Filter { only: list(only), ignore: list(ignore) } | |
| 289 | } | |
| 290 | ||
| 291 | fn trigger(event: &str, spec: &Value) -> Trigger { | |
| 292 | let mut trigger = Trigger { event: event.to_owned(), ..Trigger::default() }; | |
| 293 | match spec { | |
| 294 | Value::Object(spec) => { | |
| 295 | trigger.types = texts(spec.get("types")); | |
| 296 | trigger.branches = filter(spec, "branches", "branches-ignore"); | |
| 297 | trigger.tags = filter(spec, "tags", "tags-ignore"); | |
| 298 | trigger.paths = filter(spec, "paths", "paths-ignore"); | |
| 299 | if let Some(Value::Object(inputs)) = spec.get("inputs") { | |
| 300 | trigger.inputs = inputs.clone(); | |
| 301 | } | |
| 302 | trigger.workflows = texts(spec.get("workflows")); | |
| 303 | } | |
| 304 | Value::Array(entries) if event == "schedule" => { | |
| 305 | trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect(); | |
| 306 | } | |
| 307 | _ => {} | |
| 308 | } | |
| 309 | trigger | |
| 310 | } | |
| 311 | ||
| 312 | /// Reads a workflow. `Err` is what is wrong with the file, for the person | |
| 313 | /// who wrote it; what reads but runs differently is in `notes`. | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 314 | /// |
| 315 | /// It accepts what GitHub accepts and refuses what GitHub refuses: every | |
| 316 | /// mapping has only the keys GitHub's schema gives it ([`syntax`]), plus | |
| 317 | /// `g1t`, g1t's own settings, on the workflow, a job and a step. | |
| The statement is a month at a time, a line per kind of charge | 318 | pub fn parse(source: &str) -> Result<Workflow, String> { |
| 319 | let yaml: serde_yaml::Value = serde_yaml::from_str(source).map_err(|error| format!("It is not valid YAML: {error}"))?; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 320 | if has_merge_key(&yaml) { |
| 321 | return Err("It uses a YAML merge key (`<<`), which GitHub does not read, so g1t does not either. Write the keys out, or give a whole value an anchor (`&name`) and repeat it with `*name`.".to_owned()); | |
| 322 | } | |
| The statement is a month at a time, a line per kind of charge | 323 | let raw = yaml_to_json(&yaml); |
| 324 | let Value::Object(root) = &raw else { | |
| 325 | return Err("A workflow is a mapping with `on` and `jobs`.".to_owned()); | |
| 326 | }; | |
| 327 | let mut notes = Vec::new(); | |
| 328 | let mut note = |severity, job: Option<&str>, message: String| notes.push(Note { severity, job: job.map(str::to_owned), message }); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 329 | check_keys(root, syntax::WORKFLOW, true, "", "the workflow")?; |
| The statement is a month at a time, a line per kind of charge | 330 | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 331 | // `on`, in any of its three shapes. |
| 332 | let on = root.get("on").ok_or("`on` is missing: say which events start the workflow.")?; | |
| 333 | let known = |event: &str| syntax::event(event).ok_or_else(|| format!("`on`: GitHub has no `{event}` event, so neither does g1t.")); | |
| The statement is a month at a time, a line per kind of charge | 334 | let mut triggers = Vec::new(); |
| 335 | match on { | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 336 | Value::String(event) => { |
| 337 | known(event)?; | |
| 338 | triggers.push(trigger(event, &Value::Null)); | |
| 339 | } | |
| The statement is a month at a time, a line per kind of charge | 340 | Value::Array(events) => { |
| 341 | for event in events { | |
| 342 | let Value::String(event) = event else { return Err("`on` lists event names.".to_owned()) }; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 343 | known(event)?; |
| The statement is a month at a time, a line per kind of charge | 344 | triggers.push(trigger(event, &Value::Null)); |
| 345 | } | |
| 346 | } | |
| 347 | Value::Object(events) => { | |
| 348 | for (event, spec) in events { | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 349 | check_event(event, known(event)?, spec, &mut note)?; |
| The statement is a month at a time, a line per kind of charge | 350 | triggers.push(trigger(event, spec)); |
| 351 | } | |
| 352 | } | |
| 353 | _ => return Err("`on` is an event, a list of events, or a mapping of events to their filters.".to_owned()), | |
| 354 | } | |
| 355 | for trigger in &triggers { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 356 | if let Some((_, why)) = UNSENT_EVENTS.iter().find(|(event, _)| *event == trigger.event) { |
| 357 | note(Severity::Unsupported, None, (*why).to_owned()); | |
| 358 | } else if !SUPPORTED_EVENTS.contains(&trigger.event.as_str()) { | |
| The statement is a month at a time, a line per kind of charge | 359 | note( |
| 360 | Severity::Unsupported, | |
| 361 | None, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 362 | format!("g1t does not send GitHub's `{}` event, so that trigger never starts it.", trigger.event), |
| The statement is a month at a time, a line per kind of charge | 363 | ); |
| 364 | } | |
| 365 | if trigger.event == "pull_request_target" { | |
| 366 | note( | |
| 367 | Severity::Info, | |
| 368 | None, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 369 | "`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(), |
| The statement is a month at a time, a line per kind of charge | 370 | ); |
| 371 | } | |
| 372 | if trigger.event == "workflow_call" && triggers.len() == 1 { | |
| 373 | note(Severity::Info, None, "It is a reusable workflow: it runs when another workflow calls it.".to_owned()); | |
| 374 | } | |
| 375 | } | |
| 376 | ||
| 377 | let env = match root.get("env") { | |
| 378 | Some(Value::Object(env)) => env.clone(), | |
| 379 | _ => Map::new(), | |
| 380 | }; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 381 | if let Some(defaults) = root.get("defaults") { |
| 382 | check_defaults(defaults, "")?; | |
| 383 | } | |
| 384 | if let Some(concurrency) = root.get("concurrency") { | |
| 385 | check_concurrency(concurrency, "", None, &mut note)?; | |
| 386 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 387 | let concurrency = concurrency_of(root.get("concurrency")); |
| 388 | let permissions = match root.get("permissions") { | |
| 389 | None => None, | |
| 390 | Some(value) => { | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 391 | if let Value::Object(map) = value { |
| 392 | check_keys(map, syntax::PERMISSIONS, false, "", "`permissions`")?; | |
| 393 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 394 | let (permissions, unknown) = permissions::parse(value)?; |
| 395 | permission_notes(&unknown, None, &mut note); | |
| 396 | Some(permissions) | |
| 397 | } | |
| The statement is a month at a time, a line per kind of charge | 398 | }; |
| 399 | ||
| 400 | let Some(Value::Object(job_specs)) = root.get("jobs") else { | |
| 401 | return Err("`jobs` is missing: a workflow needs at least one job.".to_owned()); | |
| 402 | }; | |
| 403 | if job_specs.is_empty() { | |
| 404 | return Err("`jobs` is empty: a workflow needs at least one job.".to_owned()); | |
| 405 | } | |
| 406 | let mut jobs = Vec::new(); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 407 | let mut seen_ids: Vec<String> = Vec::new(); |
| The statement is a month at a time, a line per kind of charge | 408 | for (id, spec) in job_specs { |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 409 | if !syntax::valid_id(id) { |
| 410 | return Err(format!("`{id}` is not a job id GitHub accepts: {ID_RULE}")); | |
| 411 | } | |
| 412 | let lower = id.to_ascii_lowercase(); | |
| 413 | if let Some(index) = seen_ids.iter().position(|seen| *seen == lower) { | |
| 414 | let other = job_specs.keys().nth(index).cloned().unwrap_or_default(); | |
| 415 | return Err(format!("Job `{id}` is already defined as `{other}`: GitHub compares job ids without case.")); | |
| 416 | } | |
| 417 | seen_ids.push(lower); | |
| The statement is a month at a time, a line per kind of charge | 418 | let Value::Object(spec) = spec else { |
| 419 | return Err(format!("Job `{id}` is a mapping.")); | |
| 420 | }; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 421 | let place = format!("Job `{id}`"); |
| The statement is a month at a time, a line per kind of charge | 422 | let uses = text(spec.get("uses")); |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 423 | if spec.contains_key("uses") { |
| 424 | for key in ["runs-on", "steps"] { | |
| 425 | if spec.contains_key(key) { | |
| 426 | return Err(format!("Job `{id}` calls a reusable workflow with `uses`, so it has no `{key}`: the called workflow's jobs say that.")); | |
| 427 | } | |
| 428 | } | |
| 429 | check_keys(spec, syntax::CALLER_JOB, true, &place, "a job that calls a reusable workflow")?; | |
| 430 | if uses.as_deref().is_none_or(|uses| uses.trim().is_empty()) { | |
| 431 | return Err(format!("Job `{id}`: `uses` names the reusable workflow it calls.")); | |
| 432 | } | |
| 433 | match spec.get("secrets") { | |
| 434 | None | Some(Value::Object(_)) => {} | |
| 435 | Some(Value::String(inherit)) if inherit == "inherit" => {} | |
| 436 | Some(_) => return Err(format!("Job `{id}`: `secrets` is a mapping of the secrets it passes, or `inherit`.")), | |
| 437 | } | |
| 438 | } else { | |
| 439 | check_keys(spec, syntax::JOB, true, &place, "a job")?; | |
| 440 | if !spec.contains_key("runs-on") { | |
| 441 | return Err(format!("Job `{id}` has no `runs-on`: say which runner it runs on, such as `runs-on: ubuntu-latest`.")); | |
| 442 | } | |
| 443 | } | |
| 444 | check_job_parts(id, spec, &mut note)?; | |
| The statement is a month at a time, a line per kind of charge | 445 | let steps_raw = match spec.get("steps") { |
| 446 | Some(Value::Array(steps)) => steps.clone(), | |
| 447 | None if uses.is_some() => Vec::new(), | |
| 448 | None => return Err(format!("Job `{id}` has no `steps`.")), | |
| 449 | Some(_) => return Err(format!("Job `{id}`: `steps` is a list.")), | |
| 450 | }; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 451 | let runs_on = spec.get("runs-on").cloned().unwrap_or(Value::Null); |
| 452 | let labels: Vec<String> = | |
| 453 | texts(Some(&runs_on)).into_iter().chain(runs_on.get("labels").map(|l| texts(Some(l))).unwrap_or_default()).collect(); | |
| 454 | // `self-hosted`, or a runner group, sends the job to the workspace's | |
| 455 | // own runners, which may be Linux, macOS or Windows. | |
| 456 | let self_hosted = runs_on.get("group").is_some() || labels.iter().any(|label| label.eq_ignore_ascii_case("self-hosted")); | |
| The statement is a month at a time, a line per kind of charge | 457 | let mut steps = Vec::new(); |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 458 | let mut step_ids: Vec<String> = Vec::new(); |
| The statement is a month at a time, a line per kind of charge | 459 | for (index, step) in steps_raw.iter().enumerate() { |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 460 | let place = format!("Job `{id}`, step {}", index + 1); |
| The statement is a month at a time, a line per kind of charge | 461 | let Value::Object(fields) = step else { |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 462 | return Err(format!("{place}: a step is a mapping.")); |
| The statement is a month at a time, a line per kind of charge | 463 | }; |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 464 | if let Some(key) = syntax::BACKGROUND_STEPS.iter().find(|key| fields.contains_key(**key)) { |
| 465 | return Err(format!( | |
| 466 | "{place}: `{key}` is one of GitHub's background steps (`background`, `wait`, `wait-all`, `cancel`, `parallel`), which g1t does not run yet." | |
| 467 | )); | |
| 468 | } | |
| The statement is a month at a time, a line per kind of charge | 469 | let step = Step { |
| 470 | id: text(fields.get("id")), | |
| 471 | name: text(fields.get("name")), | |
| 472 | condition: text(fields.get("if")), | |
| 473 | uses: text(fields.get("uses")), | |
| 474 | run: text(fields.get("run")), | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 475 | g1t: fields.get(syntax::EXTENSION).cloned(), |
| The statement is a month at a time, a line per kind of charge | 476 | raw: step.clone(), |
| 477 | }; | |
| 478 | match (&step.uses, &step.run) { | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 479 | (Some(_), Some(_)) => return Err(format!("{place}: a step has `uses` or `run`, not both.")), |
| 480 | (None, None) => return Err(format!("{place}: a step needs `uses` or `run`.")), | |
| 481 | (Some(_), None) => check_keys(fields, syntax::ACTION_STEP, true, &place, "a step that uses an action")?, | |
| 482 | (None, Some(_)) => check_keys(fields, syntax::RUN_STEP, true, &place, "a step that runs a command")?, | |
| 483 | } | |
| 484 | if let Some(step_id) = &step.id { | |
| 485 | if !syntax::valid_id(step_id) { | |
| 486 | return Err(format!("{place}: `{step_id}` is not a step id GitHub accepts: {ID_RULE}")); | |
| 487 | } | |
| 488 | let lower = step_id.to_ascii_lowercase(); | |
| 489 | if step_ids.contains(&lower) { | |
| 490 | return Err(format!("{place}: another step of job `{id}` has the id `{step_id}` already (GitHub compares step ids without case).")); | |
| 491 | } | |
| 492 | step_ids.push(lower); | |
| The statement is a month at a time, a line per kind of charge | 493 | } |
| 494 | if let Some(uses) = &step.uses | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 495 | && let Some((severity, message)) = action_note(uses) |
| The statement is a month at a time, a line per kind of charge | 496 | { |
| 497 | note(severity, Some(id), message); | |
| 498 | } | |
| 499 | if let Some(shell) = text(fields.get("shell")) | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 500 | && !self_hosted |
| The statement is a month at a time, a line per kind of charge | 501 | && matches!(shell.as_str(), "pwsh" | "powershell" | "cmd") |
| 502 | { | |
| 503 | note(Severity::Unsupported, Some(id), format!("Steps with `shell: {shell}` need Windows or PowerShell, which g1t's Linux runners do not have.")); | |
| 504 | } | |
| 505 | steps.push(step); | |
| 506 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 507 | if self_hosted { |
| 508 | note( | |
| 509 | Severity::Info, | |
| 510 | Some(id), | |
| 511 | "`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(), | |
| 512 | ); | |
| 513 | } else { | |
| 514 | for label in &labels { | |
| 515 | let lower = label.to_ascii_lowercase(); | |
| 516 | if lower.contains("windows") || lower.contains("macos") { | |
| 517 | note( | |
| 518 | Severity::Unsupported, | |
| 519 | Some(id), | |
| 520 | 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, ...]`."), | |
| 521 | ); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 522 | } else if lower.contains("arm") { |
| 523 | note( | |
| 524 | Severity::Warning, | |
| 525 | Some(id), | |
| 526 | format!("`runs-on: {label}`: g1t's own runners are x64, so this job runs on x64, not Arm. A self-hosted Arm runner runs it as asked: `runs-on: [self-hosted, ARM64]`."), | |
| 527 | ); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 528 | } |
| The statement is a month at a time, a line per kind of charge | 529 | } |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 530 | if let Some(minutes) = spec.get("timeout-minutes").and_then(Value::as_f64) |
| 531 | && minutes > f64::from(HOSTED_MAX_TIMEOUT_MINUTES) | |
| 532 | { | |
| 533 | note( | |
| 534 | Severity::Warning, | |
| 535 | Some(id), | |
| 536 | format!("`timeout-minutes: {minutes}`: a job on g1t's own machines is stopped at {HOSTED_MAX_TIMEOUT_MINUTES} minutes. A self-hosted runner runs it for as long as it asks, up to 24 hours."), | |
| 537 | ); | |
| 538 | } | |
| The statement is a month at a time, a line per kind of charge | 539 | } |
| 540 | if spec.contains_key("services") { | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 541 | note( |
| 542 | Severity::Info, | |
| 543 | Some(id), | |
| 544 | "`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(), | |
| 545 | ); | |
| The statement is a month at a time, a line per kind of charge | 546 | } |
| 547 | if spec.contains_key("container") { | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 548 | note( |
| 549 | Severity::Info, | |
| 550 | Some(id), | |
| 551 | "`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(), | |
| 552 | ); | |
| The statement is a month at a time, a line per kind of charge | 553 | } |
| 554 | if spec.contains_key("environment") { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 555 | 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()); |
| The statement is a month at a time, a line per kind of charge | 556 | } |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 557 | let job_permissions = match spec.get("permissions") { |
| 558 | None => None, | |
| 559 | Some(value) => { | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 560 | if let Value::Object(map) = value { |
| 561 | check_keys(map, syntax::PERMISSIONS, false, &place, "`permissions`")?; | |
| 562 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 563 | let (permissions, unknown) = permissions::parse(value).map_err(|problem| format!("Job `{id}`: {problem}"))?; |
| 564 | permission_notes(&unknown, Some(id), &mut note); | |
| 565 | Some(permissions) | |
| 566 | } | |
| 567 | }; | |
| The statement is a month at a time, a line per kind of charge | 568 | let (matrix, fail_fast, max_parallel) = match spec.get("strategy") { |
| 569 | Some(Value::Object(strategy)) => ( | |
| 570 | strategy.get("matrix").cloned(), | |
| 571 | strategy.get("fail-fast").and_then(Value::as_bool).unwrap_or(true), | |
| 572 | strategy.get("max-parallel").and_then(Value::as_u64).map(|n| n as u32), | |
| 573 | ), | |
| 574 | _ => (None, true, None), | |
| 575 | }; | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 576 | if let Some(called) = uses.as_deref().filter(|uses| !uses.starts_with("./")) { |
| The statement is a month at a time, a line per kind of charge | 577 | note( |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 578 | Severity::Info, |
| The statement is a month at a time, a line per kind of charge | 579 | Some(id), |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 580 | format!( |
| 581 | "`{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." | |
| 582 | ), | |
| The statement is a month at a time, a line per kind of charge | 583 | ); |
| 584 | } | |
| 585 | jobs.push(Job { | |
| 586 | id: id.clone(), | |
| 587 | name: text(spec.get("name")), | |
| 588 | needs: texts(spec.get("needs")), | |
| 589 | condition: text(spec.get("if")), | |
| 590 | runs_on, | |
| 591 | matrix, | |
| 592 | fail_fast, | |
| 593 | max_parallel, | |
| 594 | uses, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 595 | permissions: job_permissions, |
| 596 | concurrency: concurrency_of(spec.get("concurrency")), | |
| The statement is a month at a time, a line per kind of charge | 597 | steps, |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 598 | g1t: spec.get(syntax::EXTENSION).cloned(), |
| The statement is a month at a time, a line per kind of charge | 599 | raw: Value::Object(spec.clone()), |
| 600 | }); | |
| 601 | } | |
| 602 | for job in &jobs { | |
| 603 | for need in &job.needs { | |
| 604 | if !jobs.iter().any(|other| &other.id == need) { | |
| 605 | return Err(format!("Job `{}` needs `{need}`, and there is no job called that.", job.id)); | |
| 606 | } | |
| 607 | } | |
| 608 | } | |
| 609 | let workflow = Workflow { | |
| 610 | name: text(root.get("name")), | |
| 611 | run_name: text(root.get("run-name")), | |
| 612 | triggers, | |
| 613 | env, | |
| 614 | concurrency, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 615 | permissions, |
| The statement is a month at a time, a line per kind of charge | 616 | jobs, |
| 617 | notes, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 618 | g1t: root.get(syntax::EXTENSION).cloned(), |
| The statement is a month at a time, a line per kind of charge | 619 | raw, |
| 620 | }; | |
| 621 | if workflow.job_order().len() < workflow.jobs.len() { | |
| 622 | return Err("The jobs' `needs` go round in a circle.".to_owned()); | |
| 623 | } | |
| 624 | Ok(workflow) | |
| 625 | } | |
| 626 | ||
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 627 | /// Whether a job (as written) records a deployment to its environment: |
| 628 | /// unless it says `deployment: false`, in its `environment` as on GitHub, | |
| 629 | /// or in its `g1t` settings. | |
| 630 | pub fn records_deployment(job: &Value) -> bool { | |
| 631 | let off = |value: Option<&Value>| match value { | |
| 632 | Some(Value::Bool(flag)) => !flag, | |
| 633 | Some(Value::String(text)) => text.trim() == "false", | |
| 634 | _ => false, | |
| 635 | }; | |
| 636 | !off(job.get("environment").and_then(|environment| environment.get("deployment"))) | |
| 637 | && !off(job.get(syntax::EXTENSION).and_then(|settings| settings.get("deployment"))) | |
| 638 | } | |
| 639 | ||
| 640 | /// GitHub's rule for job and step ids, as said when one breaks it. | |
| 641 | const ID_RULE: &str = "it starts with a letter or `_`, has only letters, digits, `-` and `_`, is under 100 characters, and does not start with `__`."; | |
| 642 | ||
| 643 | /// Whether a YAML merge key (`<<`) is anywhere in it. | |
| 644 | fn has_merge_key(value: &serde_yaml::Value) -> bool { | |
| 645 | match value { | |
| 646 | serde_yaml::Value::Mapping(map) => map.iter().any(|(key, value)| key.as_str() == Some("<<") || has_merge_key(value)), | |
| 647 | serde_yaml::Value::Sequence(items) => items.iter().any(has_merge_key), | |
| 648 | serde_yaml::Value::Tagged(tagged) => has_merge_key(&tagged.value), | |
| 649 | _ => false, | |
| 650 | } | |
| 651 | } | |
| 652 | ||
| 653 | /// Keys as a sentence: `a`, `b` or `c`. | |
| 654 | fn listed(keys: &[&str]) -> String { | |
| 655 | let quoted: Vec<String> = keys.iter().map(|key| format!("`{key}`")).collect(); | |
| 656 | match quoted.split_last() { | |
| 657 | None => String::new(), | |
| 658 | Some((last, [])) => last.clone(), | |
| 659 | Some((last, rest)) => format!("{} or {last}", rest.join(", ")), | |
| 660 | } | |
| 661 | } | |
| 662 | ||
| 663 | /// `message`, said of `place` ("Job `build`, step 2"), or on its own for | |
| 664 | /// the workflow's top level. | |
| 665 | fn at(place: &str, message: &str) -> String { | |
| 666 | if place.is_empty() { | |
| 667 | let mut chars = message.chars(); | |
| 668 | chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default() | |
| 669 | } else { | |
| 670 | format!("{place}: {message}") | |
| 671 | } | |
| 672 | } | |
| 673 | ||
| 674 | /// Refuses any key of `map` that is not `allowed`, as GitHub does | |
| 675 | /// ("Unexpected value"). With `extension`, `g1t` is allowed too, as a | |
| 676 | /// mapping of anything. `what` names the mapping in the message. | |
| 677 | fn check_keys(map: &Map<String, Value>, allowed: &[&str], extension: bool, place: &str, what: &str) -> Result<(), String> { | |
| 678 | for (key, value) in map { | |
| 679 | if extension && key == syntax::EXTENSION { | |
| 680 | if !value.is_object() { | |
| 681 | return Err(at(place, "`g1t` is a mapping of g1t's own settings.")); | |
| 682 | } | |
| 683 | continue; | |
| 684 | } | |
| 685 | if !allowed.contains(&key.as_str()) { | |
| 686 | return Err(at(place, &format!("unexpected `{key}` in {what}; it can have {}.", listed(allowed)))); | |
| 687 | } | |
| 688 | } | |
| 689 | Ok(()) | |
| 690 | } | |
| 691 | ||
| 692 | /// Refuses a mapping without `key`. | |
| 693 | fn require(map: &Map<String, Value>, key: &str, place: &str, what: &str) -> Result<(), String> { | |
| 694 | if map.contains_key(key) { Ok(()) } else { Err(at(place, &format!("{what} needs `{key}`."))) } | |
| 695 | } | |
| 696 | ||
| 697 | /// One event under `on` and what it is given. | |
| 698 | fn check_event(event: &str, kind: syntax::EventSpec, spec: &Value, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> { | |
| 699 | let what = format!("`on.{event}`"); | |
| 700 | match (kind, spec) { | |
| 701 | (syntax::EventSpec::Schedule, Value::Array(entries)) => { | |
| 702 | for entry in entries { | |
| 703 | let Value::Object(entry) = entry else { return Err("`on.schedule` is a list of `cron:` entries.".to_owned()) }; | |
| 704 | check_keys(entry, syntax::CRON, false, "", "an entry of `on.schedule`")?; | |
| 705 | require(entry, "cron", "", "Each entry of `on.schedule`")?; | |
| 706 | if entry.contains_key("timezone") { | |
| 707 | note( | |
| 708 | Severity::Warning, | |
| 709 | None, | |
| 710 | "`on.schedule`: g1t reads every `cron` in UTC and does not read `timezone` yet, so the schedule runs at those times in UTC.".to_owned(), | |
| 711 | ); | |
| 712 | } | |
| 713 | } | |
| 714 | Ok(()) | |
| 715 | } | |
| 716 | (syntax::EventSpec::Schedule, _) => Err("`on.schedule` is a list of `cron:` entries.".to_owned()), | |
| 717 | (_, Value::Null) => Ok(()), | |
| 718 | (syntax::EventSpec::Bare, _) => Err(format!("{what} takes no settings: write `{event}:` with nothing after it.")), | |
| 719 | (syntax::EventSpec::Filters(keys), Value::Object(map)) => { | |
| 720 | check_keys(map, keys, false, "", &what)?; | |
| 721 | for only in ["branches", "tags", "paths"] { | |
| 722 | let ignore = format!("{only}-ignore"); | |
| 723 | if map.contains_key(only) && map.contains_key(&ignore) { | |
| 724 | return Err(format!( | |
| 725 | "{what} has both `{only}` and `{ignore}`: GitHub takes one or the other for an event. To leave some out of `{only}`, start a pattern with `!`." | |
| 726 | )); | |
| 727 | } | |
| 728 | } | |
| 729 | let definitions = |key: &str| -> Result<Vec<(&String, &Value)>, String> { | |
| 730 | match map.get(key) { | |
| 731 | None | Some(Value::Null) => Ok(Vec::new()), | |
| 732 | Some(Value::Object(entries)) => Ok(entries.iter().collect()), | |
| 733 | Some(_) => Err(format!("`on.{event}.{key}` is a mapping of names to their definitions.")), | |
| 734 | } | |
| 735 | }; | |
| 736 | if event == "workflow_dispatch" { | |
| 737 | for (name, input) in definitions("inputs")? { | |
| 738 | match input { | |
| 739 | Value::Null => {} | |
| 740 | Value::Object(input) => check_keys(input, syntax::DISPATCH_INPUT, false, "", &format!("input `{name}` of {what}"))?, | |
| 741 | _ => return Err(format!("Input `{name}` of {what} is a mapping.")), | |
| 742 | } | |
| 743 | } | |
| 744 | } | |
| 745 | if event == "workflow_call" { | |
| 746 | for (name, input) in definitions("inputs")? { | |
| 747 | let Value::Object(input) = input else { return Err(format!("Input `{name}` of {what} is a mapping with its `type`.")) }; | |
| 748 | let place = format!("input `{name}` of {what}"); | |
| 749 | check_keys(input, syntax::CALL_INPUT, false, "", &place)?; | |
| 750 | require(input, "type", "", &format!("Input `{name}` of {what}"))?; | |
| 751 | } | |
| 752 | for (name, output) in definitions("outputs")? { | |
| 753 | let Value::Object(output) = output else { return Err(format!("Output `{name}` of {what} is a mapping with its `value`.")) }; | |
| 754 | check_keys(output, syntax::CALL_OUTPUT, false, "", &format!("output `{name}` of {what}"))?; | |
| 755 | require(output, "value", "", &format!("Output `{name}` of {what}"))?; | |
| 756 | } | |
| 757 | for (name, secret) in definitions("secrets")? { | |
| 758 | match secret { | |
| 759 | Value::Null => {} | |
| 760 | Value::Object(secret) => check_keys(secret, syntax::CALL_SECRET, false, "", &format!("secret `{name}` of {what}"))?, | |
| 761 | _ => return Err(format!("Secret `{name}` of {what} is a mapping, or nothing.")), | |
| 762 | } | |
| 763 | } | |
| 764 | } | |
| 765 | Ok(()) | |
| 766 | } | |
| 767 | (syntax::EventSpec::Filters(_), _) => Err(format!("{what} is a mapping of its filters, or nothing.")), | |
| 768 | } | |
| 769 | } | |
| 770 | ||
| 771 | /// `defaults`, at the top level or on a job. | |
| 772 | fn check_defaults(value: &Value, place: &str) -> Result<(), String> { | |
| 773 | let Value::Object(defaults) = value else { return Err(at(place, "`defaults` is a mapping with `run`.")) }; | |
| 774 | check_keys(defaults, syntax::DEFAULTS, false, place, "`defaults`")?; | |
| 775 | match defaults.get("run") { | |
| 776 | None | Some(Value::Null) => Ok(()), | |
| 777 | Some(Value::Object(run)) => check_keys(run, syntax::DEFAULTS_RUN, false, place, "`defaults.run`"), | |
| 778 | Some(_) => Err(at(place, "`defaults.run` is a mapping with `shell` or `working-directory`.")), | |
| 779 | } | |
| 780 | } | |
| 781 | ||
| 782 | /// `concurrency`, at the top level or on a job (`job`). | |
| 783 | fn check_concurrency(value: &Value, place: &str, job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> { | |
| 784 | let Value::Object(concurrency) = value else { return Ok(()) }; | |
| 785 | check_keys(concurrency, syntax::CONCURRENCY, false, place, "`concurrency`")?; | |
| 786 | require(concurrency, "group", place, "`concurrency`")?; | |
| 787 | if let Some(queue) = concurrency.get("queue") | |
| 788 | && text(Some(queue)).as_deref() != Some("single") | |
| 789 | { | |
| 790 | note( | |
| 791 | Severity::Warning, | |
| 792 | job, | |
| 793 | "`concurrency.queue`: g1t keeps one run or job waiting in a group, as `queue: single` does, and does not read `queue` yet.".to_owned(), | |
| 794 | ); | |
| 795 | } | |
| 796 | Ok(()) | |
| 797 | } | |
| 798 | ||
| 799 | /// A job's mappings other than its steps: each has only GitHub's keys. | |
| 800 | fn check_job_parts(id: &str, spec: &Map<String, Value>, note: &mut impl FnMut(Severity, Option<&str>, String)) -> Result<(), String> { | |
| 801 | let place = format!("Job `{id}`"); | |
| 802 | let credentials = |container: &Map<String, Value>, what: &str| -> Result<(), String> { | |
| 803 | match container.get("credentials") { | |
| 804 | Some(Value::Object(credentials)) => check_keys(credentials, syntax::CREDENTIALS, false, &place, &format!("the `credentials` of {what}")), | |
| 805 | _ => Ok(()), | |
| 806 | } | |
| 807 | }; | |
| 808 | if let Some(Value::Object(runs_on)) = spec.get("runs-on") { | |
| 809 | check_keys(runs_on, syntax::RUNS_ON, false, &place, "`runs-on`")?; | |
| 810 | } | |
| 811 | if let Some(Value::Object(strategy)) = spec.get("strategy") { | |
| 812 | check_keys(strategy, syntax::STRATEGY, false, &place, "`strategy`")?; | |
| 813 | } | |
| 814 | if let Some(Value::Object(container)) = spec.get("container") { | |
| 815 | check_keys(container, syntax::CONTAINER, false, &place, "`container`")?; | |
| 816 | credentials(container, "`container`")?; | |
| 817 | } | |
| 818 | if let Some(Value::Object(services)) = spec.get("services") { | |
| 819 | for (name, service) in services { | |
| 820 | if let Value::Object(service) = service { | |
| 821 | let what = format!("service `{name}`"); | |
| 822 | check_keys(service, syntax::SERVICE, false, &place, &what)?; | |
| 823 | credentials(service, &what)?; | |
| 824 | } | |
| 825 | } | |
| 826 | } | |
| 827 | if let Some(Value::Object(environment)) = spec.get("environment") { | |
| 828 | check_keys(environment, syntax::ENVIRONMENT, false, &place, "`environment`")?; | |
| 829 | require(environment, "name", &place, "`environment`")?; | |
| 830 | } | |
| 831 | if let Some(concurrency) = spec.get("concurrency") { | |
| 832 | check_concurrency(concurrency, &place, Some(id), note)?; | |
| 833 | } | |
| 834 | if let Some(defaults) = spec.get("defaults") { | |
| 835 | check_defaults(defaults, &place)?; | |
| 836 | } | |
| 837 | if spec.contains_key("snapshot") { | |
| 838 | note(Severity::Unsupported, Some(id), "`snapshot`: g1t does not make custom runner images from a job yet, so the job runs and no image is made.".to_owned()); | |
| 839 | } | |
| 840 | if spec.contains_key("cancel-timeout-minutes") { | |
| 841 | note(Severity::Warning, Some(id), "`cancel-timeout-minutes`: g1t does not read it yet, so a cancelled job stops in its usual time.".to_owned()); | |
| 842 | } | |
| 843 | Ok(()) | |
| 844 | } | |
| 845 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 846 | /// `concurrency`, as a group's name or a mapping with `group` and |
| 847 | /// `cancel-in-progress`. | |
| 848 | fn concurrency_of(value: Option<&Value>) -> Option<Concurrency> { | |
| 849 | match value { | |
| 850 | Some(Value::String(group)) => Some(Concurrency { group: group.clone(), cancel_in_progress: Value::Bool(false) }), | |
| 851 | Some(Value::Object(spec)) => text(spec.get("group")).map(|group| Concurrency { | |
| 852 | group, | |
| 853 | cancel_in_progress: spec.get("cancel-in-progress").cloned().unwrap_or(Value::Bool(false)), | |
| 854 | }), | |
| 855 | _ => None, | |
| 856 | } | |
| 857 | } | |
| 858 | ||
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 859 | /// The longest a job on g1t's own machines may run, whatever its |
| 860 | /// `timeout-minutes`. | |
| 861 | pub const HOSTED_MAX_TIMEOUT_MINUTES: u32 = 60; | |
| 862 | /// How long a job may run when it names no `timeout-minutes`, as on GitHub. | |
| 863 | pub const DEFAULT_TIMEOUT_MINUTES: u32 = 360; | |
| 864 | ||
| 865 | /// What to say about `permissions` names GitHub has that g1t's token has | |
| 866 | /// nothing behind. | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 867 | fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) { |
| 868 | for name in unknown { | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 869 | note(Severity::Warning, job, format!("`permissions.{name}`: g1t's token has nothing behind that permission yet, so it grants nothing.")); |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 870 | } |
| 871 | } | |
| 872 | ||
| The statement is a month at a time, a line per kind of charge | 873 | /// What to say about an action g1t runs differently, if anything. |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 874 | fn action_note(uses: &str) -> Option<(Severity, String)> { |
| The statement is a month at a time, a line per kind of charge | 875 | if uses.starts_with("docker://") { |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 876 | return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine)."))); |
| The statement is a month at a time, a line per kind of charge | 877 | } |
| 878 | let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase(); | |
| 879 | match name.as_str() { | |
| 880 | "actions/checkout" => Some((Severity::Info, "`actions/checkout` checks out from g1t.".to_owned())), | |
| 881 | "actions/cache" | "actions/cache/restore" | "actions/cache/save" => Some(( | |
| 882 | Severity::Info, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 883 | 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."), |
| The statement is a month at a time, a line per kind of charge | 884 | )), |
| 885 | "actions/upload-artifact" | "actions/download-artifact" => Some(( | |
| 886 | Severity::Info, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 887 | format!("`{name}`: g1t keeps artifacts with the run for 14 days unless the repository says otherwise, up to 5 GiB each."), |
| The statement is a month at a time, a line per kind of charge | 888 | )), |
| 889 | _ => None, | |
| 890 | } | |
| 891 | } | |
| 892 | ||
| 893 | #[cfg(test)] | |
| 894 | mod tests { | |
| 895 | use super::*; | |
| 896 | ||
| 897 | const CI: &str = r#" | |
| 898 | name: CI | |
| 899 | on: | |
| 900 | push: | |
| 901 | branches: [main] | |
| 902 | paths-ignore: ["docs/**"] | |
| 903 | pull_request: | |
| 904 | workflow_dispatch: | |
| 905 | inputs: | |
| 906 | debug: | |
| 907 | type: boolean | |
| 908 | default: false | |
| 909 | schedule: | |
| 910 | - cron: "0 3 * * *" | |
| 911 | concurrency: | |
| 912 | group: ci-${{ github.ref }} | |
| 913 | cancel-in-progress: true | |
| 914 | env: | |
| 915 | CARGO_TERM_COLOR: always | |
| 916 | jobs: | |
| 917 | test: | |
| 918 | runs-on: ${{ matrix.os }} | |
| 919 | strategy: | |
| 920 | matrix: | |
| 921 | os: [ubuntu-latest, windows-latest] | |
| 922 | node: [22, 24] | |
| 923 | steps: | |
| 924 | - uses: actions/checkout@v7 | |
| 925 | - uses: actions/setup-node@v7 | |
| 926 | with: | |
| 927 | node-version: ${{ matrix.node }} | |
| 928 | - run: npm ci | |
| 929 | - name: Test | |
| 930 | run: npm test | |
| 931 | deploy: | |
| 932 | needs: test | |
| 933 | if: github.ref == 'refs/heads/main' | |
| 934 | runs-on: ubuntu-latest | |
| 935 | steps: | |
| 936 | - run: echo deploy | |
| 937 | "#; | |
| 938 | ||
| 939 | #[test] | |
| 940 | fn a_whole_workflow_reads() { | |
| 941 | let workflow = parse(CI).unwrap(); | |
| 942 | assert_eq!(workflow.name.as_deref(), Some("CI")); | |
| 943 | assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["push", "pull_request", "workflow_dispatch", "schedule"]); | |
| 944 | let push = workflow.trigger("push").unwrap(); | |
| 945 | assert!(push.branches.allows("main")); | |
| 946 | assert!(!push.branches.allows("dev")); | |
| 947 | assert!(!push.paths.allows_paths(&["docs/a.md".into()])); | |
| 948 | assert_eq!(workflow.trigger("schedule").unwrap().crons, ["0 3 * * *"]); | |
| 949 | assert!(workflow.trigger("workflow_dispatch").unwrap().inputs.contains_key("debug")); | |
| 950 | assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}"); | |
| 951 | assert_eq!(workflow.jobs.len(), 2); | |
| 952 | assert_eq!(workflow.jobs[1].needs, ["test"]); | |
| 953 | assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7"); | |
| 954 | assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci"); | |
| 955 | assert_eq!(workflow.jobs[0].steps[3].title(), "Test"); | |
| 956 | assert_eq!(workflow.job_order(), ["test", "deploy"]); | |
| 957 | assert_eq!(workflow.env["CARGO_TERM_COLOR"], "always"); | |
| 958 | } | |
| 959 | ||
| 960 | #[test] | |
| 961 | fn short_forms_of_on() { | |
| 962 | let one = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap(); | |
| 963 | assert_eq!(one.triggers[0].event, "push"); | |
| 964 | let list = parse("on: [push, pull_request]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap(); | |
| 965 | assert_eq!(list.triggers.len(), 2); | |
| 966 | let pr = list.trigger("pull_request").unwrap(); | |
| 967 | assert!(pr.wants_type(Some("opened"))); | |
| 968 | assert!(pr.wants_type(Some("synchronize"))); | |
| 969 | assert!(!pr.wants_type(Some("closed"))); | |
| 970 | assert!(pr.wants_type(Some("ready_for_review"))); | |
| 971 | let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap(); | |
| 972 | assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed"))); | |
| 973 | assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened"))); | |
| 974 | } | |
| 975 | ||
| 976 | #[test] | |
| 977 | fn notes_say_what_runs_differently() { | |
| 978 | let workflow = parse( | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 979 | "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", |
| The statement is a month at a time, a line per kind of charge | 980 | ) |
| 981 | .unwrap(); | |
| 982 | let unsupported: Vec<&str> = | |
| 983 | 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 timeouts | 984 | assert!(unsupported.iter().any(|m| m.contains("`watch`"))); |
| 985 | 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(); | |
| 986 | assert!(!released.notes.iter().any(|n| n.severity == Severity::Unsupported)); | |
| 987 | assert!(released.trigger("release").unwrap().wants_type(Some("published"))); | |
| 988 | assert!(!released.trigger("release").unwrap().wants_type(Some("created"))); | |
| 989 | assert!(released.trigger("deployment_status").unwrap().wants_type(Some("created"))); | |
| The statement is a month at a time, a line per kind of charge | 990 | assert!(unsupported.iter().any(|m| m.contains("windows-latest"))); |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 991 | assert!(!unsupported.iter().any(|m| m.contains("services"))); |
| 992 | assert!(!unsupported.iter().any(|m| m.contains("docker://alpine"))); | |
| 993 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost"))); | |
| 994 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`"))); | |
| The statement is a month at a time, a line per kind of charge | 995 | assert!(unsupported.iter().any(|m| m.contains("pwsh"))); |
| 996 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache"))); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 997 | // Toolkit caches work on g1t, so `setup-node` with `cache:` says nothing. |
| 998 | assert!(!workflow.notes.iter().any(|n| n.message.contains("actions/setup-node"))); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 999 | assert!(workflow.notes.iter().any(|n| n.message.contains("2 GiB an entry"))); |
| 1000 | } | |
| 1001 | ||
| 1002 | #[test] | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1003 | fn notes_say_what_g1ts_machines_change_about_a_job() { |
| 1004 | let workflow = parse( | |
| 1005 | "on: push\njobs:\n arm:\n runs-on: ubuntu-24.04-arm\n timeout-minutes: 90\n steps: [{ run: 'true' }]\n call:\n uses: ./.g1t/workflows/build.yml\n strategy:\n matrix: { os: [a, b] }\n short:\n runs-on: ubuntu-latest\n timeout-minutes: 30\n steps: [{ run: 'true' }]\n mine:\n runs-on: [self-hosted, arm64]\n timeout-minutes: 300\n steps: [{ run: 'true' }]", | |
| 1006 | ) | |
| 1007 | .unwrap(); | |
| 1008 | let warned = |job: &str, text: &str| { | |
| 1009 | workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.job.as_deref() == Some(job) && n.message.contains(text)) | |
| 1010 | }; | |
| 1011 | assert!(warned("arm", "runs on x64")); | |
| 1012 | assert!(warned("arm", "stopped at 60 minutes")); | |
| 1013 | assert!(!workflow.notes.iter().any(|n| n.job.as_deref() == Some("call") && n.severity == Severity::Warning)); | |
| 1014 | assert!(!workflow.notes.iter().any(|n| matches!(n.job.as_deref(), Some("short" | "mine")) && n.severity == Severity::Warning)); | |
| 1015 | let artifacts = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ uses: actions/upload-artifact@v4 }]").unwrap(); | |
| 1016 | assert!(artifacts.notes.iter().any(|n| n.message.contains("5 GiB each"))); | |
| 1017 | } | |
| 1018 | ||
| 1019 | #[test] | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1020 | fn self_hosted_jobs_may_run_on_any_os() { |
| 1021 | let workflow = parse( | |
| 1022 | "on: push | |
| 1023 | jobs: | |
| 1024 | win: | |
| 1025 | runs-on: [self-hosted, windows] | |
| 1026 | steps: | |
| 1027 | - run: dir | |
| 1028 | shell: pwsh | |
| 1029 | mac: | |
| 1030 | runs-on: { group: Macs, labels: [macos] } | |
| 1031 | steps: [{ run: 'true' }]", | |
| 1032 | ) | |
| 1033 | .unwrap(); | |
| 1034 | assert!(!workflow.notes.iter().any(|n| n.severity == Severity::Unsupported), "{:?}", workflow.notes); | |
| 1035 | let routed: Vec<&str> = workflow.notes.iter().filter(|n| n.message.starts_with("`self-hosted`")).map(|n| n.message.as_str()).collect(); | |
| 1036 | assert_eq!(routed.len(), 2); | |
| 1037 | assert!(routed.iter().all(|m| m.contains("self-hosted runners") && !m.contains("Linux"))); | |
| The statement is a month at a time, a line per kind of charge | 1038 | } |
| 1039 | ||
| 1040 | #[test] | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1041 | fn permissions_are_read_at_both_levels() { |
| 1042 | use crate::permissions::{Access, TokenDefault}; | |
| 1043 | let workflow = parse( | |
| 1044 | "on: push | |
| 1045 | permissions: | |
| 1046 | contents: read | |
| 1047 | pull-requests: write | |
| 1048 | jobs: | |
| 1049 | plain: | |
| 1050 | runs-on: ubuntu-latest | |
| 1051 | steps: [{ run: 'true' }] | |
| 1052 | release: | |
| 1053 | runs-on: ubuntu-latest | |
| 1054 | permissions: | |
| 1055 | contents: write | |
| 1056 | steps: [{ run: 'true' }] | |
| 1057 | quiet: | |
| 1058 | runs-on: ubuntu-latest | |
| 1059 | permissions: {} | |
| 1060 | steps: [{ run: 'true' }]", | |
| 1061 | ) | |
| 1062 | .unwrap(); | |
| 1063 | let plain = workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted); | |
| 1064 | assert_eq!(plain.get("pull-requests"), Access::Write); | |
| 1065 | assert_eq!(plain.get("contents"), Access::Read); | |
| 1066 | // A job's own permissions replace the workflow's whole. | |
| 1067 | let release = workflow.jobs[1].permissions(&workflow, TokenDefault::Permissive); | |
| 1068 | assert_eq!(release.get("contents"), Access::Write); | |
| 1069 | assert_eq!(release.get("pull-requests"), Access::None); | |
| 1070 | assert_eq!(workflow.jobs[2].permissions(&workflow, TokenDefault::Permissive).scopes(), ["repo:read"]); | |
| 1071 | // Without any, the repository's default. | |
| 1072 | let bare = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap(); | |
| 1073 | assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("contents"), Access::Read); | |
| 1074 | assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None); | |
| 1075 | assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write); | |
| 1076 | // What reads but grants nothing is said. | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1077 | let odd = parse("on: push\npermissions: { id-token: write, artifact-metadata: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap(); |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1078 | assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued"); |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1079 | assert!(odd.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("`permissions.artifact-metadata`"))); |
| 1080 | // A name GitHub's token does not have is refused, as GitHub does; | |
| 1081 | // `metadata` is always `read` and cannot be written. | |
| 1082 | for name in ["wiki", "metadata"] { | |
| 1083 | let problem = parse(&format!("on: push\npermissions: {{ {name}: read }}\njobs:\n a:\n runs-on: x\n steps: [{{ run: 'true' }}]")).unwrap_err(); | |
| 1084 | assert!(problem.contains(&format!("Unexpected `{name}` in `permissions`")), "{problem}"); | |
| 1085 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1086 | assert!(parse("on: push\npermissions: read\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap_err().contains("read-all")); |
| 1087 | assert!( | |
| 1088 | parse("on: push\njobs:\n a:\n runs-on: x\n permissions: { contents: admin }\n steps: [{ run: 'true' }]") | |
| 1089 | .unwrap_err() | |
| 1090 | .contains("Job `a`") | |
| 1091 | ); | |
| 1092 | } | |
| 1093 | ||
| 1094 | #[test] | |
| 1095 | fn a_job_has_its_own_concurrency() { | |
| 1096 | let workflow = parse( | |
| 1097 | "on: push | |
| 1098 | jobs: | |
| 1099 | deploy: | |
| 1100 | runs-on: ubuntu-latest | |
| 1101 | concurrency: | |
| 1102 | group: deploy-${{ github.ref }} | |
| 1103 | cancel-in-progress: true | |
| 1104 | steps: [{ run: 'true' }] | |
| 1105 | named: | |
| 1106 | runs-on: ubuntu-latest | |
| 1107 | concurrency: just-one | |
| 1108 | steps: [{ run: 'true' }]", | |
| 1109 | ) | |
| 1110 | .unwrap(); | |
| 1111 | let deploy = workflow.jobs[0].concurrency.as_ref().unwrap(); | |
| 1112 | assert_eq!(deploy.group, "deploy-${{ github.ref }}"); | |
| 1113 | assert_eq!(deploy.cancel_in_progress, Value::Bool(true)); | |
| 1114 | assert_eq!(workflow.jobs[1].concurrency.as_ref().unwrap().group, "just-one"); | |
| 1115 | assert!(workflow.concurrency.is_none()); | |
| 1116 | } | |
| 1117 | ||
| 1118 | #[test] | |
| 1119 | fn events_g1t_never_sends_are_said_and_pull_request_target_is_the_base() { | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1120 | let workflow = parse("on: [create, delete, label, repository_dispatch, pull_request_target]\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap(); |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1121 | let unsupported: Vec<&str> = workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect(); |
| 1122 | assert_eq!(unsupported.len(), 1, "{unsupported:?}"); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1123 | assert!(unsupported[0].contains("`label`")); |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1124 | let target = workflow.notes.iter().find(|n| n.message.starts_with("`pull_request_target`")).unwrap(); |
| 1125 | assert!(target.message.contains("default branch")); | |
| 1126 | assert!(!target.message.contains("on the pull request's head")); | |
| 1127 | } | |
| 1128 | ||
| 1129 | #[test] | |
| The statement is a month at a time, a line per kind of charge | 1130 | fn mistakes_are_explained() { |
| 1131 | let problem = |yaml: &str| parse(yaml).unwrap_err(); | |
| 1132 | assert!(problem("jobs: {}").contains("`on` is missing")); | |
| 1133 | assert!(problem("on: push").contains("`jobs` is missing")); | |
| 1134 | assert!(problem("on: push\njobs:\n a:\n runs-on: x").contains("no `steps`")); | |
| 1135 | assert!(problem("on: push\njobs:\n a:\n runs-on: x\n steps: [{ name: nothing }]").contains("`uses` or `run`")); | |
| 1136 | assert!(problem("on: push\njobs:\n a:\n needs: b\n runs-on: x\n steps: [{ run: x }]").contains("no job called that")); | |
| 1137 | assert!( | |
| 1138 | 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 }]") | |
| 1139 | .contains("circle") | |
| 1140 | ); | |
| 1141 | assert!(problem("on: push\njobs: [1]").contains("`jobs`")); | |
| 1142 | assert!(problem(": : :").contains("not valid YAML")); | |
| 1143 | } | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1144 | |
| 1145 | #[test] | |
| 1146 | fn only_githubs_keys_and_g1ts_own() { | |
| 1147 | let problem = |yaml: &str| parse(yaml).unwrap_err(); | |
| 1148 | let job = "\njobs:\n build:\n runs-on: x\n steps: [{ run: 'true' }]"; | |
| 1149 | assert!(problem(&format!("on: push\nrun_name: x{job}")).starts_with("Unexpected `run_name` in the workflow; it can have `on`, ")); | |
| 1150 | assert!(problem("on: push\njobs:\n build:\n runs-on: x\n step: []").starts_with("Job `build`: unexpected `step` in a job;")); | |
| 1151 | let step = problem("on: push\njobs:\n build:\n runs-on: x\n steps:\n - run: 'true'\n - uses: actions/checkout@v5\n shell: bash"); | |
| 1152 | assert!(step.starts_with("Job `build`, step 2: unexpected `shell` in a step that uses an action;"), "{step}"); | |
| 1153 | assert!(problem("on: push\njobs:\n build:\n steps: [{ run: 'true' }]").contains("no `runs-on`")); | |
| 1154 | assert!(problem("on: push\njobs:\n build:\n uses: ./.g1t/workflows/b.yml\n runs-on: x").contains("no `runs-on`")); | |
| 1155 | assert!(problem("on: push\njobs:\n my.job:\n runs-on: x\n steps: [{ run: 'true' }]").contains("`my.job` is not a job id")); | |
| 1156 | assert!(problem("on: pushh\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").contains("no `pushh` event")); | |
| 1157 | ||
| 1158 | // `g1t` is g1t's own, at each level, and kept as written. | |
| 1159 | let workflow = parse( | |
| 1160 | "on: push | |
| 1161 | g1t: { note: top } | |
| 1162 | jobs: | |
| 1163 | deploy: | |
| 1164 | runs-on: x | |
| 1165 | environment: production | |
| 1166 | g1t: { deployment: false } | |
| 1167 | steps: | |
| 1168 | - run: 'true' | |
| 1169 | g1t: { anything: [1, 2] }", | |
| 1170 | ) | |
| 1171 | .unwrap(); | |
| 1172 | assert_eq!(workflow.g1t, Some(serde_json::json!({ "note": "top" }))); | |
| 1173 | assert_eq!(workflow.jobs[0].g1t, Some(serde_json::json!({ "deployment": false }))); | |
| 1174 | assert_eq!(workflow.jobs[0].steps[0].g1t, Some(serde_json::json!({ "anything": [1, 2] }))); | |
| 1175 | assert!(problem(&format!("on: push\ng1t: yes{job}")).contains("`g1t` is a mapping")); | |
| 1176 | assert!(!records_deployment(&workflow.jobs[0].raw)); | |
| 1177 | assert!(!records_deployment(&serde_json::json!({ "environment": { "name": "p", "deployment": false } }))); | |
| 1178 | assert!(records_deployment(&serde_json::json!({ "environment": { "name": "p", "deployment": true } }))); | |
| 1179 | assert!(records_deployment(&serde_json::json!({ "environment": "p" }))); | |
| 1180 | } | |
| 1181 | ||
| 1182 | #[test] | |
| 1183 | fn aliases_read_and_merge_keys_do_not() { | |
| 1184 | let aliased = parse("on: push\njobs:\n a:\n runs-on: x\n steps: &steps\n - run: 'true'\n b:\n runs-on: x\n steps: *steps").unwrap(); | |
| 1185 | assert_eq!(aliased.jobs[1].steps.len(), 1); | |
| 1186 | let merged = parse("on: push\njobs:\n a: &job\n runs-on: x\n steps: [{ run: 'true' }]\n b:\n <<: *job\n name: B").unwrap_err(); | |
| 1187 | assert!(merged.contains("merge key"), "{merged}"); | |
| 1188 | } | |
| The statement is a month at a time, a line per kind of charge | 1189 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.