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 balance shows what was spent this month and in total | 1 | //! A run's life: made, its jobs waiting on the jobs they need, each job |
| 2 | //! skipped or expanded into its matrix and queued, started in a sandbox | |
| 3 | //! when its workspace has room, reporting its steps and logs as it goes, | |
| 4 | //! and finished; the run finishes with its last job. | |
| 5 | ||
| 6 | use g1t_actions::events::{RunInfo, runner_context}; | |
| 7 | use g1t_actions::expr::{self, Scope, Status}; | |
| 8 | use g1t_actions::matrix; | |
| 9 | use g1t_actions::workflow::{self, Workflow}; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 10 | use g1t_contracts::access::Capability; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 11 | use g1t_contracts::actions::{JobCallArgs, RunActionArgs, RunApproval, StartJobArgs, WorkflowRun}; |
| 12 | use g1t_contracts::identity::{CreateJobTokenArgs, CreatedAccessToken, RevokeJobTokensArgs}; | |
| The balance shows what was spent this month and in total | 13 | use g1t_contracts::repos::{Repo, RepoPath}; |
| 14 | use g1t_contracts::time::rfc3339; | |
| 15 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 16 | use g1t_kit::now_ms; | |
| 17 | use g1t_secrets::{random_hex, same, sha256_hex}; | |
| 18 | use serde::Deserialize; | |
| 19 | use serde_json::{Map, Value, json}; | |
| 20 | use worker::Result; | |
| 21 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 22 | use crate::protection::Gate; |
| The balance shows what was spent this month and in total | 23 | use crate::sync::WorkflowRow; |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 24 | use crate::{Actions, Count, MAX_TIMEOUT_MINUTES, RUNNING_PER_WORKSPACE, SELF_HOSTED_MAX_TIMEOUT_MINUTES, SILENT_MS, SITE, check, fail, optional, repo_path}; |
| 25 | use g1t_contracts::runners::{Wanted, waiting_reason}; | |
| The balance shows what was spent this month and in total | 26 | |
| 27 | /// The most log one job keeps, in bytes; past it, the log says so and stops. | |
| 28 | const MAX_LOG_BYTES: usize = 4 * 1024 * 1024; | |
| 29 | /// The most a single log report may add. | |
| 30 | const MAX_CHUNK_BYTES: usize = 256 * 1024; | |
| 31 | const MAX_ANNOTATIONS: usize = 50; | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 32 | /// The most one step's job summary keeps, in bytes, and how many steps of a |
| 33 | /// job may have one, as on GitHub. | |
| 34 | pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024; | |
| 35 | pub(crate) const MAX_SUMMARIES: u32 = 20; | |
| 36 | ||
| 37 | /// Whether a step's summary of `adding` bytes is kept: a job keeps up to | |
| 38 | /// 20 steps' summaries (`steps` it has, `mine` this step's bytes so far) | |
| 39 | /// of up to 1 MiB each. | |
| 40 | pub(crate) fn summary_fits(steps: u32, mine: Option<usize>, adding: usize) -> bool { | |
| 41 | if adding == 0 { | |
| 42 | return false; | |
| 43 | } | |
| 44 | match mine { | |
| 45 | Some(used) => used + adding <= MAX_SUMMARY_BYTES, | |
| 46 | None => steps < MAX_SUMMARIES && adding <= MAX_SUMMARY_BYTES, | |
| 47 | } | |
| 48 | } | |
| The balance shows what was spent this month and in total | 49 | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 50 | /// The repository whose unfinished runs `event` stops, by id: one deleted, |
| 51 | /// or archived (not unarchived). | |
| 52 | pub fn stops_runs(event: &g1t_contracts::events::Event) -> Option<String> { | |
| 53 | let stops = match event.kind.as_str() { | |
| 54 | "repo.deleted" => true, | |
| 55 | "repo.archived" => event.data["archived"].as_bool() != Some(false), | |
| 56 | _ => false, | |
| 57 | }; | |
| 58 | if !stops { | |
| 59 | return None; | |
| 60 | } | |
| 61 | event.repo_id.clone().or_else(|| event.data["repoId"].as_str().map(str::to_owned)) | |
| 62 | } | |
| 63 | ||
| The balance shows what was spent this month and in total | 64 | pub struct NewRun { |
| 65 | pub repo: Repo, | |
| 66 | pub path: String, | |
| 67 | pub source: String, | |
| 68 | pub workflow: Workflow, | |
| 69 | pub info: RunInfo, | |
| 70 | pub action: Option<String>, | |
| 71 | pub pull: Option<u32>, | |
| 72 | pub title: String, | |
| 73 | pub inputs: Map<String, Value>, | |
| 74 | pub event_key: String, | |
| 75 | pub actor_id: Option<String>, | |
| 76 | pub actor: Option<String>, | |
| 77 | pub trusted: bool, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 78 | /// Why the run waits for someone with the Write role to approve it |
| 79 | /// before anything starts: a pull request from outside (protection.rs). | |
| 80 | pub approval: Option<String>, | |
| The balance shows what was spent this month and in total | 81 | } |
| 82 | ||
| 83 | #[derive(Clone, Deserialize)] | |
| 84 | pub struct RunRow { | |
| 85 | pub id: String, | |
| 86 | pub workflow_id: String, | |
| 87 | pub repo_id: String, | |
| 88 | pub repo: String, | |
| 89 | pub path: String, | |
| 90 | pub name: String, | |
| 91 | pub title: String, | |
| 92 | pub number: u64, | |
| 93 | pub attempt: u64, | |
| 94 | pub event: String, | |
| 95 | pub action: Option<String>, | |
| 96 | pub git_ref: String, | |
| 97 | pub sha: String, | |
| 98 | pub pull: Option<u32>, | |
| 99 | pub status: String, | |
| 100 | pub conclusion: Option<String>, | |
| 101 | pub error: Option<String>, | |
| 102 | pub actor: Option<String>, | |
| 103 | pub actor_id: Option<String>, | |
| 104 | pub source: String, | |
| 105 | pub info: String, | |
| 106 | pub inputs: String, | |
| 107 | pub trusted: u32, | |
| 108 | pub concurrency_group: Option<String>, | |
| 109 | pub created_at: String, | |
| 110 | pub started_at: Option<String>, | |
| 111 | pub finished_at: Option<String>, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 112 | /// JSON `RunApproval`, for a run that needed approval (migration 0006). |
| 113 | #[serde(default)] | |
| 114 | pub approval: Option<String>, | |
| 115 | /// Whether its concurrency group cancels what it replaces. | |
| 116 | #[serde(default)] | |
| 117 | pub cancel_in_progress: u32, | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 118 | /// Who started the current attempt, once it is re-run (migration 0009). |
| 119 | #[serde(default)] | |
| 120 | pub triggering_actor: Option<String>, | |
| 121 | /// The current attempt runs with debug logging. | |
| 122 | #[serde(default)] | |
| 123 | pub debug: u32, | |
| The balance shows what was spent this month and in total | 124 | } |
| 125 | ||
| 126 | impl RunRow { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 127 | pub fn approval(&self) -> Option<RunApproval> { |
| 128 | self.approval.as_deref().and_then(|text| serde_json::from_str(text).ok()) | |
| 129 | } | |
| 130 | ||
| The balance shows what was spent this month and in total | 131 | pub fn info(&self) -> RunInfo { |
| 132 | let mut info: RunInfo = serde_json::from_str(&self.info).unwrap_or_default(); | |
| 133 | info.run_id = self.id.clone(); | |
| 134 | info.run_number = self.number; | |
| 135 | info.run_attempt = self.attempt; | |
| 136 | info.workflow_path = self.path.clone(); | |
| 137 | if info.workflow.is_empty() { | |
| 138 | info.workflow = self.name.clone(); | |
| 139 | } | |
| 140 | info | |
| 141 | } | |
| 142 | ||
| 143 | pub fn inputs(&self) -> Map<String, Value> { | |
| 144 | serde_json::from_str(&self.inputs).unwrap_or_default() | |
| 145 | } | |
| 146 | ||
| 147 | pub fn summary(&self) -> WorkflowRun { | |
| 148 | WorkflowRun { | |
| 149 | id: self.id.clone(), | |
| 150 | workflow_id: self.workflow_id.clone(), | |
| 151 | path: self.path.clone(), | |
| 152 | name: self.name.clone(), | |
| 153 | title: self.title.clone(), | |
| 154 | number: self.number, | |
| 155 | attempt: self.attempt, | |
| 156 | event: self.event.clone(), | |
| 157 | git_ref: self.git_ref.clone(), | |
| 158 | sha: self.sha.clone(), | |
| 159 | pull: self.pull, | |
| 160 | status: self.status.clone(), | |
| 161 | conclusion: self.conclusion.clone(), | |
| 162 | error: self.error.clone(), | |
| 163 | actor: self.actor.clone(), | |
| 164 | created_at: self.created_at.clone(), | |
| 165 | started_at: self.started_at.clone(), | |
| 166 | finished_at: self.finished_at.clone(), | |
| 167 | } | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | #[derive(Clone, Deserialize)] | |
| 172 | pub struct JobRow { | |
| 173 | pub id: String, | |
| 174 | pub run_id: String, | |
| 175 | pub repo_id: String, | |
| 176 | pub namespace: String, | |
| 177 | pub key: String, | |
| 178 | pub ordinal: u32, | |
| 179 | pub name: String, | |
| 180 | pub needs: String, | |
| 181 | pub matrix: Option<String>, | |
| 182 | pub status: String, | |
| 183 | pub conclusion: Option<String>, | |
| 184 | pub steps: String, | |
| 185 | pub annotations: String, | |
| 186 | pub outputs: String, | |
| 187 | pub reason: Option<String>, | |
| 188 | pub token_hash: Option<String>, | |
| 189 | pub timeout_minutes: u32, | |
| 190 | pub continue_on_error: u32, | |
| 191 | pub max_parallel: Option<u32>, | |
| 192 | /// Set for a job that calls a reusable workflow, and for that | |
| 193 | /// workflow's jobs (see migration 0002). | |
| 194 | pub call: Option<String>, | |
| 195 | pub seen_at: Option<String>, | |
| 196 | pub started_at: Option<String>, | |
| 197 | pub finished_at: Option<String>, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 198 | /// For a job whose `runs-on` names self-hosted runners: what it asks |
| 199 | /// for, as a JSON array (see `g1t_contracts::runners::Wanted`), when it | |
| 200 | /// started waiting, and the runner that took it (migration 0004). | |
| 201 | #[serde(default)] | |
| 202 | pub labels: Option<String>, | |
| 203 | #[serde(default)] | |
| 204 | pub queued_at: Option<String>, | |
| 205 | #[serde(default)] | |
| 206 | pub runner_id: Option<String>, | |
| 207 | #[serde(default)] | |
| 208 | pub runner_name: Option<String>, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 209 | /// The environment it names, read when its needs were done; a job its |
| 210 | /// rules hold is `pending` (migration 0006). | |
| 211 | #[serde(default)] | |
| 212 | pub environment: Option<String>, | |
| 213 | /// Its own `concurrency` group, and whether that cancels what it replaces. | |
| 214 | #[serde(default)] | |
| 215 | pub concurrency_group: Option<String>, | |
| 216 | #[serde(default)] | |
| 217 | pub cancel_in_progress: u32, | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 218 | /// When it was told to stop, while it runs its cleanup steps |
| 219 | /// (migration 0009). | |
| 220 | #[serde(default)] | |
| 221 | pub cancel_requested_at: Option<String>, | |
| 222 | } | |
| 223 | ||
| 224 | /// How long a cancelled job has to run its `if: always()` and `cancelled()` | |
| 225 | /// steps and its post steps before it is stopped outright, as on GitHub. | |
| 226 | pub const CANCEL_GRACE_MS: u64 = 5 * 60 * 1000; | |
| 227 | ||
| 228 | /// Which jobs a re-run runs again. | |
| 229 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 230 | pub enum Rerun<'a> { | |
| 231 | All, | |
| 232 | /// Those that did not succeed. | |
| 233 | Failed, | |
| 234 | /// One job's key. | |
| 235 | Job(&'a str), | |
| 236 | } | |
| 237 | ||
| 238 | /// The keys a re-run runs again, in `order`: those `which` names and, | |
| 239 | /// transitively, every key that needs one of them. `needs` gives a key's | |
| 240 | /// needs; `succeeded` whether every job of a key succeeded. | |
| 241 | pub fn rerun_keys(order: &[&str], needs: impl Fn(&str) -> Vec<String>, succeeded: impl Fn(&str) -> bool, which: Rerun) -> Vec<String> { | |
| 242 | let mut again: Vec<String> = Vec::new(); | |
| 243 | for key in order { | |
| 244 | let named = match which { | |
| 245 | Rerun::All => true, | |
| 246 | Rerun::Failed => !succeeded(key), | |
| 247 | Rerun::Job(job) => *key == job, | |
| 248 | }; | |
| 249 | if named || needs(key).iter().any(|need| again.contains(need)) { | |
| 250 | again.push((*key).to_owned()); | |
| 251 | } | |
| 252 | } | |
| 253 | again | |
| 254 | } | |
| 255 | ||
| 256 | /// The key under `jobs:` a job belongs to: a called workflow's jobs | |
| 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. | 257 | /// (`build/test`, or `build[1]/test` for a matrix's second call) run again |
| 258 | /// with the job that calls it (`build`). | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 259 | pub fn top_key(key: &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. | 260 | let first = key.split('/').next().unwrap_or(key); |
| 261 | first.split('[').next().unwrap_or(first) | |
| 262 | } | |
| 263 | ||
| 264 | /// What the keys of the jobs a caller's call makes start with: the | |
| 265 | /// caller's key, and for a matrix the combination's index after it | |
| 266 | /// (`build[1]`), so each combination's call has jobs of its own. A job id | |
| 267 | /// has no `[`, so the two never meet. | |
| 268 | fn call_prefix(key: &str, index: Option<usize>) -> String { | |
| 269 | match index { | |
| 270 | Some(index) => format!("{key}[{index}]"), | |
| 271 | None => key.to_owned(), | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 275 | /// Whether a run may call one more reusable workflow, `file`, `depth` | |
| 276 | /// levels below the workflow it started from, having called `called` | |
| 277 | /// already (one name per call; repeats are one workflow). As on GitHub: ten | |
| 278 | /// levels of workflows at most, the top one and nine reusable ones below | |
| 279 | /// it, and 50 different reusable workflows in a run. | |
| 280 | fn call_allowed(depth: u64, called: &[&str], file: &str) -> std::result::Result<(), String> { | |
| 281 | if depth > MAX_CALL_DEPTH { | |
| 282 | return Err(format!( | |
| 283 | "`{file}` would be the {}th level of workflows: a run connects at most ten, the workflow it started from and nine levels of reusable workflows below it.", | |
| 284 | depth + 1 | |
| 285 | )); | |
| 286 | } | |
| 287 | let mut unique: Vec<&str> = called.to_vec(); | |
| 288 | unique.push(file); | |
| 289 | unique.sort_unstable(); | |
| 290 | unique.dedup(); | |
| 291 | if unique.len() > MAX_CALLED_WORKFLOWS { | |
| 292 | return Err(format!("Calling `{file}` would make {} reusable workflows in this run: a run calls at most {MAX_CALLED_WORKFLOWS} different ones.", unique.len())); | |
| 293 | } | |
| 294 | Ok(()) | |
| 295 | } | |
| 296 | ||
| 297 | /// What a job is shown as: its `name` (else its id), under the job whose | |
| 298 | /// call made it (`caller`), read with its matrix when it has an | |
| 299 | /// expression, else with the matrix's values after it. | |
| 300 | fn shown_name(job: &workflow::Job, caller: Option<&str>, combination: &Map<String, Value>, scope: &Scope) -> String { | |
| 301 | let base = job.name.clone().unwrap_or(job.id.clone()); | |
| 302 | let base = match caller { | |
| 303 | Some(caller) => format!("{caller} / {base}"), | |
| 304 | None => base, | |
| 305 | }; | |
| 306 | if expr::has_expression(&base) { | |
| 307 | expr::interpolate(&base, scope).unwrap_or(base) | |
| 308 | } else if job.matrix.is_some() { | |
| 309 | matrix::job_name(&base, combination) | |
| 310 | } else { | |
| 311 | base | |
| 312 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 313 | } |
| 314 | ||
| 315 | /// Debug logging for a job, as a re-run with it turned on gives GitHub's: | |
| 316 | /// `RUNNER_DEBUG=1` and `runner.debug`, and `ACTIONS_STEP_DEBUG` and | |
| 317 | /// `ACTIONS_RUNNER_DEBUG` set, which the runner reads as the secrets of | |
| 318 | /// those names (`::debug::` lines shown, and how each step's `if` read). | |
| 319 | pub fn debug_logging(variables: &mut Map<String, Value>, runner: &mut Value) { | |
| 320 | variables.insert("RUNNER_DEBUG".into(), json!("1")); | |
| 321 | variables.insert("ACTIONS_STEP_DEBUG".into(), json!("true")); | |
| 322 | variables.insert("ACTIONS_RUNNER_DEBUG".into(), json!("true")); | |
| 323 | runner["debug"] = json!("1"); | |
| The balance shows what was spent this month and in total | 324 | } |
| 325 | ||
| 326 | impl JobRow { | |
| 327 | pub fn needs(&self) -> Vec<String> { | |
| 328 | serde_json::from_str(&self.needs).unwrap_or_default() | |
| 329 | } | |
| 330 | ||
| 331 | pub fn call(&self) -> Option<Value> { | |
| 332 | self.call.as_deref().and_then(|call| serde_json::from_str(call).ok()) | |
| 333 | } | |
| 334 | ||
| 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. | 335 | /// For a job that calls a reusable workflow: what the keys of the jobs |
| 336 | /// its call makes start with (`call_prefix`). | |
| 337 | pub fn call_prefix(&self) -> Option<String> { | |
| 338 | let call = self.call().filter(|call| call["role"] == "caller")?; | |
| 339 | Some(call["prefix"].as_str().unwrap_or(&self.key).to_owned()) | |
| 340 | } | |
| 341 | ||
| 342 | /// A job that calls a reusable workflow and has not called it yet: it | |
| 343 | /// waits for room under its matrix's `max-parallel`, or in its | |
| 344 | /// concurrency group. | |
| 345 | pub fn holds_call(&self) -> bool { | |
| 346 | self.status == "waiting" && self.call_prefix().is_some() | |
| 347 | } | |
| 348 | ||
| The balance shows what was spent this month and in total | 349 | /// For a job of a called workflow: that workflow, the job's own id in |
| 350 | /// it, and the job. | |
| 351 | pub fn callee(&self) -> Option<(Workflow, workflow::Job, Value)> { | |
| 352 | let call = self.call().filter(|call| call["role"] == "callee")?; | |
| 353 | let called = workflow::parse(call["source"].as_str()?).ok()?; | |
| 354 | let job = called.jobs.iter().find(|job| call["job"].as_str() == Some(job.id.as_str()))?.clone(); | |
| 355 | Some((called, job, call)) | |
| 356 | } | |
| 357 | } | |
| 358 | ||
| 359 | /// A job to decide on: its key, its definition, and what it needs, as | |
| 360 | /// (name in `needs`, key of the jobs). | |
| 361 | type Unit = (String, workflow::Job, Vec<(String, String)>); | |
| 362 | ||
| 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. | 363 | /// How many levels of reusable workflows may sit below the workflow a run |
| 364 | /// started from, as on GitHub (ten levels of workflows in all). | |
| 365 | const MAX_CALL_DEPTH: u64 = 9; | |
| 366 | /// How many different reusable workflows a run may call, as on GitHub. | |
| 367 | const MAX_CALLED_WORKFLOWS: usize = 50; | |
| The balance shows what was spent this month and in total | 368 | |
| 369 | /// What the jobs of one key came to, for `needs.<key>`. | |
| 370 | fn key_result(rows: &[&JobRow]) -> &'static str { | |
| 371 | let failed = |row: &&&JobRow| row.conclusion.as_deref() == Some("failure") && row.continue_on_error == 0; | |
| 372 | if rows.iter().any(|row| failed(&row)) { | |
| 373 | "failure" | |
| 374 | } else if rows.iter().any(|row| row.conclusion.as_deref() == Some("cancelled")) { | |
| 375 | "cancelled" | |
| 376 | } else if rows.iter().all(|row| row.conclusion.as_deref() == Some("skipped")) { | |
| 377 | "skipped" | |
| 378 | } else { | |
| 379 | "success" | |
| 380 | } | |
| 381 | } | |
| 382 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 383 | /// Whether any job before `key` failed: one it needs, or one those need, |
| 384 | /// however far back. A job after a skipped one still sees the failure | |
| 385 | /// that skipped it, as GitHub's failure() does. `needs_of`: each key's | |
| 386 | /// needs, as keys; `failed`: whether a key's jobs came to a failure. | |
| 387 | fn ancestor_failed(needs_of: &std::collections::HashMap<&str, Vec<&str>>, key: &str, failed: impl Fn(&str) -> bool) -> bool { | |
| 388 | let mut seen = std::collections::HashSet::new(); | |
| 389 | let mut stack: Vec<&str> = needs_of.get(key).cloned().unwrap_or_default(); | |
| 390 | while let Some(next) = stack.pop() { | |
| 391 | if !seen.insert(next) { | |
| 392 | continue; | |
| 393 | } | |
| 394 | if failed(next) { | |
| 395 | return true; | |
| 396 | } | |
| 397 | stack.extend(needs_of.get(next).into_iter().flatten().copied()); | |
| 398 | } | |
| 399 | false | |
| 400 | } | |
| 401 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 402 | /// A job's `environment:`, as deployments read it: its name, the address in |
| 403 | /// `environment.url` once its expressions are filled in from the run, and | |
| 404 | /// whether the job deploys to it. A job with `deployment: false` only reads | |
| 405 | /// the environment's secrets and variables, and makes no deployment. | |
| 406 | #[derive(Clone, Debug, Default, PartialEq)] | |
| 407 | pub(crate) struct JobEnvironment { | |
| 408 | pub(crate) name: String, | |
| 409 | pub(crate) url: Option<String>, | |
| 410 | pub(crate) deploys: bool, | |
| 411 | } | |
| 412 | ||
| 413 | /// The environment `environment:` names, as written in `raw` (a job), with | |
| 414 | /// `contexts` to fill in expressions: null when it names none, or only | |
| 415 | /// through an expression this cannot read before the job runs. | |
| 416 | pub(crate) fn environment_of(raw: &Value, contexts: &Map<String, Value>) -> Option<JobEnvironment> { | |
| 417 | let scope = Scope { contexts, status: Status::Success, hash_files: None }; | |
| 418 | let plain = |value: &Value| -> Option<String> { | |
| 419 | let text = match value { | |
| 420 | Value::String(text) if text.contains("${{") => expr::interpolate_value(value, &scope).ok().map(|v| expr::to_text(&v))?, | |
| 421 | Value::String(text) => text.clone(), | |
| 422 | _ => return None, | |
| 423 | }; | |
| 424 | let text = text.trim().to_owned(); | |
| 425 | (!text.is_empty()).then_some(text) | |
| 426 | }; | |
| 427 | match raw.get("environment")? { | |
| 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. | 428 | name @ Value::String(_) => Some(JobEnvironment { name: plain(name)?, url: None, deploys: workflow::records_deployment(raw) }), |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 429 | Value::Object(env) => { |
| 430 | let name = plain(env.get("name")?)?; | |
| 431 | let url = env | |
| 432 | .get("url") | |
| 433 | .and_then(plain) | |
| 434 | .filter(|url| url.starts_with("https://") || url.starts_with("http://")); | |
| 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. | 435 | Some(JobEnvironment { name, url, deploys: workflow::records_deployment(raw) }) |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 436 | } |
| 437 | _ => None, | |
| 438 | } | |
| 439 | } | |
| 440 | ||
| 441 | /// The contexts a job's `runs-on` and `environment` are read with before it | |
| 442 | /// runs: the run's `github`, its inputs, and the job's matrix. | |
| 443 | fn start_contexts(run: &RunRow, job: &JobRow, key: &str) -> Map<String, Value> { | |
| 444 | let mut contexts = Map::new(); | |
| 445 | contexts.insert("github".into(), run.info().context(key, "", run.action.as_deref())); | |
| 446 | contexts.insert("inputs".into(), Value::Object(run.inputs())); | |
| 447 | contexts.insert("matrix".into(), job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or_else(|| json!({}))); | |
| 448 | contexts | |
| 449 | } | |
| 450 | ||
| 451 | /// The job as its workflow (or the workflow it calls) defines it. | |
| 452 | fn job_spec(run: &RunRow, job: &JobRow) -> Option<workflow::Job> { | |
| 453 | match job.callee() { | |
| 454 | Some((_, spec, _)) => Some(spec), | |
| 455 | None => workflow::parse(&run.source).ok().and_then(|w| w.jobs.into_iter().find(|j| j.id == job.key)), | |
| 456 | } | |
| 457 | } | |
| 458 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 459 | /// The environment a job deploys to, if it deploys: by the name read when |
| 460 | /// its needs were done (which may have needed their outputs), else as | |
| 461 | /// its `environment:` reads now. | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 462 | pub(crate) fn deploys_to(run: &RunRow, job: &JobRow) -> Option<JobEnvironment> { |
| 463 | let spec = job_spec(run, job)?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 464 | let read = environment_of(&spec.raw, &start_contexts(run, job, &spec.id)); |
| 465 | let env = match (read, &job.environment) { | |
| 466 | (Some(env), Some(name)) => Some(JobEnvironment { name: name.clone(), ..env }), | |
| 467 | (Some(env), None) => Some(env), | |
| 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. | 468 | (None, Some(name)) => Some(JobEnvironment { name: name.clone(), url: None, deploys: workflow::records_deployment(&spec.raw) }), |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 469 | (None, None) => None, |
| 470 | }; | |
| 471 | env.filter(|env| env.deploys) | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 472 | } |
| 473 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 474 | /// What the runner is told about a job it starts, from its workflow: the |
| 475 | /// environment it names plainly, and the machine its `runs-on` asks for | |
| 476 | /// (`instance_for`; none for the standard one). | |
| 477 | #[derive(Default)] | |
| 478 | struct StartDetails { | |
| 479 | environment: Option<String>, | |
| 480 | instance: Option<String>, | |
| 481 | } | |
| 482 | ||
| 483 | fn start_details(run: &RunRow, job: &JobRow) -> StartDetails { | |
| 484 | let spec = match job.callee() { | |
| 485 | Some((_, spec, _)) => spec, | |
| 486 | None => match workflow::parse(&run.source).ok().and_then(|w| w.jobs.into_iter().find(|j| j.id == job.key)) { | |
| 487 | Some(spec) => spec, | |
| 488 | None => return StartDetails::default(), | |
| 489 | }, | |
| 490 | }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 491 | // As read when its needs were done, an expression's included. |
| 492 | let environment = job.environment.clone(); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 493 | // `runs-on` as the job was queued with: its matrix and the run's |
| 494 | // inputs. A label that needs more than those is the standard machine. | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 495 | let contexts = start_contexts(run, job, &spec.id); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 496 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; |
| 497 | let labels: Vec<String> = match expr::interpolate_value(&spec.runs_on, &scope).unwrap_or(Value::Null) { | |
| 498 | Value::String(label) => vec![label], | |
| 499 | Value::Array(labels) => labels.iter().map(expr::to_text).collect(), | |
| 500 | Value::Object(given) => match given.get("labels") { | |
| 501 | Some(Value::Array(labels)) => labels.iter().map(expr::to_text).collect(), | |
| 502 | Some(label) => vec![expr::to_text(label)], | |
| 503 | None => Vec::new(), | |
| 504 | }, | |
| 505 | _ => Vec::new(), | |
| 506 | }; | |
| 507 | let instance = g1t_contracts::actions::instance_for(&labels); | |
| 508 | StartDetails { | |
| 509 | environment, | |
| 510 | instance: (instance != g1t_contracts::actions::STANDARD_INSTANCE).then(|| instance.label.to_owned()), | |
| 511 | } | |
| 512 | } | |
| 513 | ||
| The balance shows what was spent this month and in total | 514 | fn now() -> String { |
| 515 | rfc3339(now_ms()) | |
| 516 | } | |
| 517 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 518 | /// How a finished run went for one environment, from the conclusions of |
| 519 | /// the jobs that deploy to it: failed if any failed, an error if any was | |
| 520 | /// cancelled, else a success; nothing when none ran (all skipped). | |
| 521 | pub(crate) fn deployment_outcome(conclusions: &[Option<String>]) -> Option<&'static str> { | |
| 522 | let ran: Vec<&str> = conclusions.iter().flatten().map(String::as_str).filter(|c| *c != "skipped").collect(); | |
| 523 | if ran.is_empty() { | |
| 524 | return None; | |
| 525 | } | |
| 526 | if ran.iter().any(|c| *c == "failure" || *c == "timed_out") { | |
| 527 | return Some("failure"); | |
| 528 | } | |
| 529 | if ran.contains(&"cancelled") { | |
| 530 | return Some("error"); | |
| 531 | } | |
| 532 | Some("success") | |
| 533 | } | |
| 534 | ||
| The balance shows what was spent this month and in total | 535 | impl Actions { |
| 536 | pub async fn run_row(&self, id: &str) -> Result<Option<RunRow>> { | |
| 537 | self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<RunRow>(None).await | |
| 538 | } | |
| 539 | ||
| 540 | pub async fn job_rows(&self, run_id: &str) -> Result<Vec<JobRow>> { | |
| 541 | self.db | |
| 542 | .prepare("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid") | |
| 543 | .bind(&[run_id.into()])? | |
| 544 | .all() | |
| 545 | .await? | |
| 546 | .results::<JobRow>() | |
| 547 | } | |
| 548 | ||
| 549 | pub async fn run_summary(&self, id: &str) -> Result<Outcome<WorkflowRun>> { | |
| 550 | Ok(match self.run_row(id).await? { | |
| 551 | Some(row) => Outcome::Ok(row.summary()), | |
| 552 | None => fail(FailureCode::NotFound, "No such run."), | |
| 553 | }) | |
| 554 | } | |
| 555 | ||
| 556 | /// The contexts every expression outside a job's steps may use. | |
| 557 | fn base_contexts(run: &RunRow, vars: &Map<String, Value>, job: &str) -> Map<String, Value> { | |
| 558 | let mut contexts = Map::new(); | |
| 559 | contexts.insert("github".into(), run.info().context(job, "", run.action.as_deref())); | |
| 560 | contexts.insert("inputs".into(), Value::Object(run.inputs())); | |
| 561 | contexts.insert("vars".into(), Value::Object(vars.clone())); | |
| 562 | contexts.insert("needs".into(), json!({})); | |
| 563 | contexts.insert("runner".into(), runner_context()); | |
| 564 | contexts | |
| 565 | } | |
| 566 | ||
| 567 | /// Makes a run and its jobs, and starts what can start. `None` when the | |
| 568 | /// event already started this workflow. | |
| 569 | pub async fn create_run(&self, new: NewRun) -> Result<Option<String>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 570 | // Nothing starts on an archived repository. A deleted one is never |
| 571 | // found to start anything on. | |
| 572 | if new.repo.archived() { | |
| 573 | return Ok(None); | |
| 574 | } | |
| The balance shows what was spent this month and in total | 575 | let workflow_row = self.workflow_row(&new.repo, &new.path, &new.workflow.display_name(&new.path), &new.source).await?; |
| 576 | let numbered = self | |
| 577 | .db | |
| 578 | .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n") | |
| 579 | .bind(&[workflow_row.id.as_str().into()])? | |
| 580 | .first::<Count>(None) | |
| 581 | .await? | |
| 582 | .map_or(1, |count| count.n); | |
| 583 | let id = new_id("run", now_ms()); | |
| 584 | let mut info = new.info.clone(); | |
| 585 | info.workflow = new.workflow.display_name(&new.path); | |
| 586 | info.workflow_path = new.path.clone(); | |
| 587 | info.run_id = id.clone(); | |
| 588 | info.run_number = u64::from(numbered); | |
| 589 | let vars = self | |
| 590 | .variables_for(&new.repo.id, &format!("{}/{}", new.repo.namespace, new.repo.name), None, new.trusted) | |
| 591 | .await?; | |
| 592 | ||
| 593 | // run-name and the concurrency group read github, inputs and vars. | |
| 594 | let mut contexts = Map::new(); | |
| 595 | contexts.insert("github".into(), info.context("", "", new.action.as_deref())); | |
| 596 | contexts.insert("inputs".into(), Value::Object(new.inputs.clone())); | |
| 597 | contexts.insert("vars".into(), Value::Object(vars)); | |
| 598 | let scope = Scope { | |
| 599 | contexts: &contexts, | |
| 600 | status: Status::Success, | |
| 601 | hash_files: None, | |
| 602 | }; | |
| 603 | let title = new | |
| 604 | .workflow | |
| 605 | .run_name | |
| 606 | .as_deref() | |
| 607 | .and_then(|run_name| expr::interpolate(run_name, &scope).ok()) | |
| 608 | .filter(|title| !title.trim().is_empty()) | |
| 609 | .unwrap_or(new.title.clone()); | |
| 610 | let group = new.workflow.concurrency.as_ref().and_then(|c| expr::interpolate(&c.group, &scope).ok()); | |
| 611 | let cancel_in_progress = new | |
| 612 | .workflow | |
| 613 | .concurrency | |
| 614 | .as_ref() | |
| 615 | .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok()) | |
| 616 | .is_some_and(|value| expr::truthy(&value)); | |
| 617 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 618 | let approval = new.approval.as_ref().map(|reason| RunApproval { state: "required".into(), reason: reason.clone(), approved_by: None }); |
| The balance shows what was spent this month and in total | 619 | let inserted = self |
| 620 | .db | |
| 621 | .prepare( | |
| 622 | "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, action, git_ref, sha, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 623 | pull, status, actor, actor_id, source, info, inputs, trusted, concurrency_group, event_key, created_at, |
| 624 | approval, cancel_in_progress) | |
| 625 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", | |
| The balance shows what was spent this month and in total | 626 | ) |
| 627 | .bind(&[ | |
| 628 | id.as_str().into(), | |
| 629 | workflow_row.id.as_str().into(), | |
| 630 | new.repo.id.as_str().into(), | |
| 631 | format!("{}/{}", new.repo.namespace, new.repo.name).into(), | |
| 632 | new.path.as_str().into(), | |
| 633 | info.workflow.as_str().into(), | |
| 634 | title.as_str().into(), | |
| 635 | numbered.into(), | |
| 636 | info.event_name.as_str().into(), | |
| 637 | optional(new.action.as_deref()), | |
| 638 | info.git_ref.as_str().into(), | |
| 639 | info.sha.as_str().into(), | |
| 640 | new.pull.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 641 | if approval.is_some() { "action_required" } else { "queued" }.into(), |
| The balance shows what was spent this month and in total | 642 | optional(new.actor.as_deref()), |
| 643 | optional(new.actor_id.as_deref()), | |
| 644 | new.source.as_str().into(), | |
| 645 | serde_json::to_string(&info)?.into(), | |
| 646 | serde_json::to_string(&new.inputs)?.into(), | |
| 647 | u32::from(new.trusted).into(), | |
| 648 | optional(group.as_deref()), | |
| 649 | new.event_key.as_str().into(), | |
| 650 | now().into(), | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 651 | approval.as_ref().map(serde_json::to_string).transpose()?.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 652 | u32::from(cancel_in_progress).into(), | |
| The balance shows what was spent this month and in total | 653 | ])? |
| 654 | .first::<Value>(None) | |
| 655 | .await?; | |
| 656 | if inserted.is_none() { | |
| 657 | return Ok(None); | |
| 658 | } | |
| 659 | if let Some(run) = self.run_row(&id).await? { | |
| 660 | self.report_pending(&run).await?; | |
| 661 | } | |
| 662 | ||
| 663 | // Every job, waiting; each is expanded when the jobs it needs are done. | |
| 664 | let mut statements = Vec::new(); | |
| 665 | for job in &new.workflow.jobs { | |
| 666 | statements.push( | |
| 667 | self.db | |
| 668 | .prepare( | |
| 669 | "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting')", | |
| 670 | ) | |
| 671 | .bind(&[ | |
| 672 | new_id("job", now_ms()).into(), | |
| 673 | id.as_str().into(), | |
| 674 | new.repo.id.as_str().into(), | |
| 675 | new.repo.namespace.as_str().into(), | |
| 676 | job.id.as_str().into(), | |
| 677 | job.name.clone().filter(|n| !expr::has_expression(n)).unwrap_or(job.id.clone()).into(), | |
| 678 | serde_json::to_string(&job.needs)?.into(), | |
| 679 | ])?, | |
| 680 | ); | |
| 681 | } | |
| 682 | self.db.batch(statements).await?; | |
| 683 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 684 | // A pull request's run from outside waits to be approved; it joins |
| 685 | // its concurrency group once it is (protection.rs, approve_run). | |
| 686 | if approval.is_some() { | |
| 687 | return Ok(Some(id)); | |
| 688 | } | |
| 689 | if let Some(run) = self.run_row(&id).await? { | |
| 690 | self.enter_group(&run).await?; | |
| 691 | } | |
| 692 | Ok(Some(id)) | |
| 693 | } | |
| 694 | ||
| 695 | /// A run about to start: one run at a time per concurrency group, as on | |
| 696 | /// GitHub. A newer run replaces one that waits in the group, and with | |
| 697 | /// `cancel-in-progress` the one that runs; otherwise it waits as | |
| 698 | /// `pending` for the one that runs. Then it moves along. | |
| 699 | pub(crate) async fn enter_group(&self, run: &RunRow) -> Result<()> { | |
| 700 | if let Some(group) = &run.concurrency_group { | |
| 701 | let cancel_in_progress = run.cancel_in_progress != 0; | |
| The balance shows what was spent this month and in total | 702 | let others = self |
| 703 | .db | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 704 | .prepare( |
| 705 | "SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND id != ? AND status NOT IN ('completed', 'action_required') ORDER BY id", | |
| 706 | ) | |
| 707 | .bind(&[run.repo_id.as_str().into(), group.as_str().into(), run.id.as_str().into()])? | |
| The balance shows what was spent this month and in total | 708 | .all() |
| 709 | .await? | |
| 710 | .results::<RunRow>()?; | |
| 711 | for other in &others { | |
| 712 | if cancel_in_progress || other.status == "pending" { | |
| 713 | // A newer run replaces a waiting one, as on GitHub. | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 714 | self.cancel_run(other, "A newer run in the same concurrency group replaced it.", false).await?; |
| The balance shows what was spent this month and in total | 715 | } |
| 716 | } | |
| 717 | if !cancel_in_progress && others.iter().any(|other| other.status != "pending") { | |
| 718 | self.db | |
| 719 | .prepare("UPDATE runs SET status = 'pending' WHERE id = ?") | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 720 | .bind(&[run.id.as_str().into()])? |
| The balance shows what was spent this month and in total | 721 | .run() |
| 722 | .await?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 723 | return Ok(()); |
| The balance shows what was spent this month and in total | 724 | } |
| 725 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 726 | self.advance(&run.id).await |
| The balance shows what was spent this month and in total | 727 | } |
| 728 | ||
| 729 | /// A run that could not start, such as for a workflow file that does not read. | |
| 730 | #[allow(clippy::too_many_arguments)] | |
| 731 | pub async fn record_failed_run( | |
| 732 | &self, | |
| 733 | row: &WorkflowRow, | |
| 734 | git_ref: &str, | |
| 735 | sha: &str, | |
| 736 | event_key: &str, | |
| 737 | actor_id: Option<&str>, | |
| 738 | actor: &str, | |
| 739 | problem: &str, | |
| 740 | ) -> Result<()> { | |
| 741 | let numbered = self | |
| 742 | .db | |
| 743 | .prepare("UPDATE workflows SET run_count = run_count + 1 WHERE id = ? RETURNING run_count AS n") | |
| 744 | .bind(&[row.id.as_str().into()])? | |
| 745 | .first::<Count>(None) | |
| 746 | .await? | |
| 747 | .map_or(1, |count| count.n); | |
| 748 | let at = now(); | |
| 749 | self.db | |
| 750 | .prepare( | |
| 751 | "INSERT OR IGNORE INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, git_ref, sha, status, | |
| 752 | conclusion, error, actor, actor_id, source, info, event_key, created_at, finished_at) | |
| 753 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'push', ?, ?, 'completed', 'failure', ?, ?, ?, ?, '{}', ?, ?, ?)", | |
| 754 | ) | |
| 755 | .bind(&[ | |
| 756 | new_id("run", now_ms()).into(), | |
| 757 | row.id.as_str().into(), | |
| 758 | row.repo_id.as_str().into(), | |
| 759 | row.repo.as_str().into(), | |
| 760 | row.path.as_str().into(), | |
| 761 | row.path.as_str().into(), | |
| 762 | "Invalid workflow file".into(), | |
| 763 | numbered.into(), | |
| 764 | git_ref.into(), | |
| 765 | sha.into(), | |
| 766 | problem.into(), | |
| 767 | actor.into(), | |
| 768 | optional(actor_id), | |
| 769 | row.source.as_str().into(), | |
| 770 | event_key.into(), | |
| 771 | at.as_str().into(), | |
| 772 | at.as_str().into(), | |
| 773 | ])? | |
| 774 | .run() | |
| 775 | .await?; | |
| 776 | Ok(()) | |
| 777 | } | |
| 778 | ||
| 779 | /// Moves a run along: jobs whose needs are done are decided on, and | |
| 780 | /// jobs that can start are started. | |
| 781 | pub async fn advance(&self, run_id: &str) -> Result<()> { | |
| 782 | // Each pass may finish jobs (skipped ones), which may free others. | |
| 783 | for _ in 0..20 { | |
| 784 | let Some(run) = self.run_row(run_id).await? else { return Ok(()) }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 785 | if matches!(run.status.as_str(), "completed" | "pending" | "action_required") { |
| The balance shows what was spent this month and in total | 786 | return Ok(()); |
| 787 | } | |
| 788 | let workflow = match workflow::parse(&run.source) { | |
| 789 | Ok(workflow) => workflow, | |
| 790 | Err(problem) => { | |
| 791 | self.finish_run(&run, Some(&problem)).await?; | |
| 792 | return Ok(()); | |
| 793 | } | |
| 794 | }; | |
| 795 | let jobs = self.job_rows(run_id).await?; | |
| 796 | let mut changed = false; | |
| 797 | // What to decide on: the workflow's jobs, and the jobs of the | |
| 798 | // workflows they call, each with its needs as (name, key). | |
| 799 | let mut units: Vec<Unit> = workflow | |
| 800 | .jobs | |
| 801 | .iter() | |
| 802 | .map(|job| (job.id.clone(), job.clone(), job.needs.iter().map(|n| (n.clone(), n.clone())).collect())) | |
| 803 | .collect(); | |
| 804 | let mut seen = std::collections::HashSet::new(); | |
| 805 | for row in &jobs { | |
| 806 | if !seen.insert(row.key.clone()) { | |
| 807 | continue; | |
| 808 | } | |
| 809 | if let Some((_, job, call)) = row.callee() { | |
| 810 | let parent = call["parent"].as_str().unwrap_or_default().to_owned(); | |
| 811 | let needs = job.needs.iter().map(|n| (n.clone(), format!("{parent}/{n}"))).collect(); | |
| 812 | units.push((row.key.clone(), job, needs)); | |
| 813 | } | |
| 814 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 815 | // Each key's needs, as keys, to look back through for failure(). |
| 816 | let needs_of: std::collections::HashMap<&str, Vec<&str>> = | |
| 817 | units.iter().map(|(key, _, needs)| (key.as_str(), needs.iter().map(|(_, need)| need.as_str()).collect())).collect(); | |
| 818 | let key_failed = |key: &str| key_result(&jobs.iter().filter(|row| row.key == key).collect::<Vec<_>>()) == "failure"; | |
| The balance shows what was spent this month and in total | 819 | for (key, job, needs) in &units { |
| 820 | let rows: Vec<&JobRow> = jobs.iter().filter(|row| &row.key == key).collect(); | |
| 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. | 821 | if rows.is_empty() || !rows.iter().all(|row| row.status == "waiting" && !row.holds_call()) { |
| The balance shows what was spent this month and in total | 822 | continue; |
| 823 | } | |
| 824 | let needed: Vec<(&String, Vec<&JobRow>)> = | |
| 825 | needs.iter().map(|(name, need)| (name, jobs.iter().filter(|row| &row.key == need).collect())).collect(); | |
| 826 | if !needed.iter().all(|(_, rows)| rows.iter().all(|row| row.status == "completed")) { | |
| 827 | continue; | |
| 828 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 829 | let failed_before = ancestor_failed(&needs_of, key, key_failed); |
| 830 | self.decide(&run, job, rows[0], &needed, failed_before).await?; | |
| The balance shows what was spent this month and in total | 831 | changed = true; |
| 832 | } | |
| 833 | // A job that called a workflow finishes with that workflow's jobs. | |
| 834 | for row in jobs.iter().filter(|row| row.status == "calling") { | |
| 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. | 835 | let prefix = row.call_prefix().unwrap_or(row.key.clone()); |
| The balance shows what was spent this month and in total | 836 | let children: Vec<&JobRow> = jobs |
| 837 | .iter() | |
| 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. | 838 | .filter(|child| child.call().is_some_and(|call| call["role"] == "callee" && call["parent"].as_str() == Some(prefix.as_str()))) |
| The balance shows what was spent this month and in total | 839 | .collect(); |
| 840 | if !children.is_empty() && children.iter().all(|child| child.status == "completed") { | |
| 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. | 841 | self.finish_call(&run, row, &children).await?; |
| The balance shows what was spent this month and in total | 842 | changed = true; |
| 843 | } | |
| 844 | } | |
| 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. | 845 | // Calls that wait start while their matrix's `max-parallel` and |
| 846 | // their concurrency group have room. | |
| 847 | let mut calling: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); | |
| 848 | for row in jobs.iter().filter(|row| row.status == "calling") { | |
| 849 | *calling.entry(row.key.as_str()).or_default() += 1; | |
| 850 | } | |
| 851 | for row in jobs.iter().filter(|row| row.holds_call()) { | |
| 852 | let running = calling.get(row.key.as_str()).copied().unwrap_or(0); | |
| 853 | if row.max_parallel.is_some_and(|max| running >= max) || self.group_busy(row).await? { | |
| 854 | continue; | |
| 855 | } | |
| 856 | if self.start_call(row).await? { | |
| 857 | calling.insert(row.key.as_str(), running + 1); | |
| 858 | changed = true; | |
| 859 | } | |
| 860 | } | |
| The balance shows what was spent this month and in total | 861 | if !changed { |
| 862 | break; | |
| 863 | } | |
| 864 | } | |
| 865 | self.start_queued().await?; | |
| 866 | self.finish_if_done(run_id).await | |
| 867 | } | |
| 868 | ||
| 869 | /// Decides on one job whose needs are done: skip it, fail it, or expand | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 870 | /// it into its matrix and queue it. `failed_before`: whether any job |
| 871 | /// before it failed, however far back (`ancestor_failed`). | |
| 872 | async fn decide(&self, run: &RunRow, job: &workflow::Job, row: &JobRow, needed: &[(&String, Vec<&JobRow>)], failed_before: bool) -> Result<()> { | |
| The balance shows what was spent this month and in total | 873 | let vars = self.variables_for(&run.repo_id, &run.repo, None, run.trusted != 0).await?; |
| 874 | let mut contexts = Self::base_contexts(run, &vars, &job.id); | |
| 875 | // A called workflow's jobs read the inputs they were called with. | |
| 876 | let call = row.call(); | |
| 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. | 877 | // Shown under the job whose call made it, by that job's name. |
| 878 | let caller = call.as_ref().filter(|c| c["role"] == "callee").and_then(|c| { | |
| 879 | c["title"].as_str().map(str::to_owned).or_else(|| c["parent"].as_str().map(|parent| parent.replace('/', " / "))) | |
| 880 | }); | |
| The balance shows what was spent this month and in total | 881 | if let Some(call) = call.as_ref().filter(|c| c["role"] == "callee") { |
| 882 | contexts.insert("inputs".into(), call["inputs"].clone()); | |
| 883 | } | |
| 884 | let mut needs = Map::new(); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 885 | let mut results = Vec::new(); |
| The balance shows what was spent this month and in total | 886 | for (key, rows) in needed { |
| 887 | let result = key_result(rows); | |
| 888 | let mut outputs = Map::new(); | |
| 889 | for row in rows { | |
| 890 | if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) { | |
| 891 | outputs.extend(more); | |
| 892 | } | |
| 893 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 894 | results.push(result); |
| The balance shows what was spent this month and in total | 895 | needs.insert((*key).clone(), json!({ "result": result, "outputs": outputs })); |
| 896 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 897 | // As on GitHub: a need that was skipped makes success() false but |
| 898 | // not failure(); failure() is a failure anywhere before the job. | |
| 899 | let status = expr::job_status(results, failed_before, run.conclusion.as_deref() == Some("cancelled")); | |
| The balance shows what was spent this month and in total | 900 | contexts.insert("needs".into(), Value::Object(needs)); |
| 901 | let scope = Scope { | |
| 902 | contexts: &contexts, | |
| 903 | status, | |
| 904 | hash_files: None, | |
| 905 | }; | |
| 906 | let condition = job.condition.as_deref().unwrap_or_default(); | |
| 907 | match expr::condition(condition, &scope) { | |
| 908 | Ok(true) => {} | |
| 909 | Ok(false) => return self.skip_job(row, None).await, | |
| 910 | Err(problem) => return self.fail_job(row, &format!("Its `if` does not read: {problem}")).await, | |
| 911 | } | |
| 912 | // Its matrix, which may come from a needed job's outputs. | |
| 913 | let combinations = match &job.matrix { | |
| 914 | None => vec![Map::new()], | |
| 915 | Some(matrix) => { | |
| 916 | let value = match expr::interpolate_value(matrix, &scope) { | |
| 917 | Ok(value) => value, | |
| 918 | Err(problem) => return self.fail_job(row, &format!("Its matrix does not read: {problem}")).await, | |
| 919 | }; | |
| 920 | match matrix::expand(&value) { | |
| 921 | Ok(combinations) if !combinations.is_empty() => combinations, | |
| 922 | Ok(_) => return self.fail_job(row, "Its matrix makes no jobs.").await, | |
| 923 | Err(problem) => return self.fail_job(row, &problem).await, | |
| 924 | } | |
| 925 | } | |
| 926 | }; | |
| 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. | 927 | // A job that calls a reusable workflow calls it once for each. |
| 928 | if let Some(uses) = &job.uses { | |
| 929 | return self.call_workflow(run, job, row, uses, &contexts, &combinations, caller.as_deref()).await; | |
| 930 | } | |
| The balance shows what was spent this month and in total | 931 | let total = combinations.len(); |
| 932 | let raw = job.raw.as_object().cloned().unwrap_or_default(); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 933 | // Whether pull requests from forks may use self-hosted runners here, |
| 934 | // asked once, and only for a run that is not trusted. | |
| 935 | let mut forks_allowed: Option<bool> = None; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 936 | // Each concurrency group its jobs join, and whether it cancels. |
| 937 | let mut groups: Vec<(String, bool)> = Vec::new(); | |
| The balance shows what was spent this month and in total | 938 | let mut statements = Vec::new(); |
| 939 | for (index, combination) in combinations.iter().enumerate() { | |
| 940 | let mut contexts = contexts.clone(); | |
| 941 | contexts.insert("matrix".into(), Value::Object(combination.clone())); | |
| 942 | contexts.insert( | |
| 943 | "strategy".into(), | |
| 944 | json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }), | |
| 945 | ); | |
| 946 | let scope = Scope { | |
| 947 | contexts: &contexts, | |
| 948 | status: Status::Success, | |
| 949 | hash_files: None, | |
| 950 | }; | |
| 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. | 951 | let name = shown_name(job, caller.as_deref(), combination, &scope); |
| The balance shows what was spent this month and in total | 952 | let runs_on = expr::interpolate_value(&job.runs_on, &scope).unwrap_or(Value::Null); |
| 953 | let labels = match &runs_on { | |
| 954 | Value::String(label) => vec![label.clone()], | |
| 955 | Value::Array(labels) => labels.iter().map(expr::to_text).collect(), | |
| 956 | Value::Object(spec) => spec.get("labels").map(|l| match l { | |
| 957 | Value::Array(labels) => labels.iter().map(expr::to_text).collect(), | |
| 958 | other => vec![expr::to_text(other)], | |
| 959 | }).unwrap_or_default(), | |
| 960 | _ => Vec::new(), | |
| 961 | }; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 962 | // `runs-on: self-hosted` (or a group): the workspace's own |
| 963 | // machines, which may run any OS. Otherwise g1t's Linux sandboxes. | |
| 964 | let group = match &runs_on { | |
| 965 | Value::Object(spec) => spec.get("group").map(expr::to_text), | |
| 966 | _ => None, | |
| 967 | }; | |
| 968 | let wanted = Wanted::of(&labels, group.as_deref()); | |
| 969 | let mut reason = if wanted.self_hosted { | |
| 970 | None | |
| 971 | } else { | |
| 972 | labels | |
| 973 | .iter() | |
| 974 | .find(|label| { | |
| 975 | let lower = label.to_ascii_lowercase(); | |
| 976 | lower.contains("windows") || lower.contains("macos") | |
| 977 | }) | |
| 978 | .map(|label| { | |
| 979 | let os = if label.to_ascii_lowercase().contains("windows") { "windows" } else { "macos" }; | |
| 980 | format!("`runs-on: {label}`: g1t's own runners are Linux. A self-hosted runner can run it: `runs-on: [self-hosted, {os}]`.") | |
| 981 | }) | |
| 982 | }; | |
| 983 | // A pull request from a fork runs code anyone could write: never | |
| 984 | // on the workspace's machines unless it said they may. | |
| 985 | if wanted.self_hosted && run.trusted == 0 { | |
| 986 | let allowed = match forks_allowed { | |
| 987 | Some(allowed) => allowed, | |
| 988 | None => { | |
| 989 | let allowed = self.effective_runner_settings(&row.namespace, Some(&run.repo_id)).await?.fork_pull_requests; | |
| 990 | forks_allowed = Some(allowed); | |
| 991 | allowed | |
| 992 | } | |
| 993 | }; | |
| 994 | if !allowed { | |
| 995 | reason = Some( | |
| 996 | "Pull requests from forks do not run on self-hosted runners here. An admin can allow it under Settings, Runners.".to_owned(), | |
| 997 | ); | |
| 998 | } | |
| 999 | } | |
| 1000 | let max_minutes = if wanted.self_hosted { SELF_HOSTED_MAX_TIMEOUT_MINUTES } else { MAX_TIMEOUT_MINUTES }; | |
| The balance shows what was spent this month and in total | 1001 | let timeout = raw |
| 1002 | .get("timeout-minutes") | |
| 1003 | .and_then(|value| expr::interpolate_value(value, &scope).ok()) | |
| 1004 | .and_then(|value| value.as_f64().or_else(|| expr::to_text(&value).parse().ok())) | |
| 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. | 1005 | .map_or(g1t_actions::workflow::DEFAULT_TIMEOUT_MINUTES.min(max_minutes), |minutes| (minutes.ceil() as u32).clamp(1, max_minutes)); |
| The balance shows what was spent this month and in total | 1006 | let continue_on_error = raw |
| 1007 | .get("continue-on-error") | |
| 1008 | .and_then(|value| expr::interpolate_value(value, &scope).ok()) | |
| 1009 | .is_some_and(|value| expr::truthy(&value)); | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1010 | let (mut status, mut conclusion, mut finished) = match &reason { |
| The balance shows what was spent this month and in total | 1011 | Some(_) => ("completed", Some("failure"), Some(now())), |
| 1012 | None => ("queued", None, None), | |
| 1013 | }; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1014 | // A self-hosted job waits, saying for what, until a runner takes it. |
| 1015 | let (labels_json, queued_at) = if wanted.self_hosted && reason.is_none() { | |
| 1016 | reason = Some(waiting_reason(&wanted)); | |
| 1017 | (Some(serde_json::to_string(&wanted.stored())?), Some(now())) | |
| 1018 | } else { | |
| 1019 | (None, None) | |
| 1020 | }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1021 | // The environment it names, an expression read now, and what |
| 1022 | // that environment's protection rules say: it starts, waits as | |
| 1023 | // `pending` (protection.rs), or may not deploy there. | |
| 1024 | let environment = environment_of(&job.raw, &contexts).map(|env| env.name); | |
| 1025 | if status == "queued" | |
| 1026 | && let Some(name) = &environment | |
| 1027 | { | |
| 1028 | match self.gate(run, name).await? { | |
| 1029 | Gate::Open => {} | |
| 1030 | Gate::Held(why) => { | |
| 1031 | status = "pending"; | |
| 1032 | reason = Some(why); | |
| 1033 | } | |
| 1034 | Gate::Refused(why) => { | |
| 1035 | (status, conclusion, finished) = ("completed", Some("failure"), Some(now())); | |
| 1036 | reason = Some(why); | |
| 1037 | } | |
| 1038 | } | |
| 1039 | } | |
| 1040 | // Its own concurrency group. | |
| 1041 | let job_group = job | |
| 1042 | .concurrency | |
| 1043 | .as_ref() | |
| 1044 | .and_then(|c| expr::interpolate(&c.group, &scope).ok()) | |
| 1045 | .map(|group| group.trim().to_owned()) | |
| 1046 | .filter(|group| !group.is_empty()); | |
| 1047 | let job_cancel = job | |
| 1048 | .concurrency | |
| 1049 | .as_ref() | |
| 1050 | .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok()) | |
| 1051 | .is_some_and(|value| expr::truthy(&value)); | |
| 1052 | if status != "completed" | |
| 1053 | && let Some(group) = &job_group | |
| 1054 | && !groups.iter().any(|(known, _)| known == group) | |
| 1055 | { | |
| 1056 | groups.push((group.clone(), job_cancel)); | |
| 1057 | } | |
| The balance shows what was spent this month and in total | 1058 | let values: Vec<worker::wasm_bindgen::JsValue> = vec![ |
| 1059 | name.into(), | |
| 1060 | serde_json::to_string(combination)?.into(), | |
| 1061 | status.into(), | |
| 1062 | optional(conclusion), | |
| 1063 | optional(reason.as_deref()), | |
| 1064 | timeout.into(), | |
| 1065 | u32::from(continue_on_error).into(), | |
| 1066 | job.max_parallel.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 1067 | optional(finished.as_deref()), | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1068 | optional(labels_json.as_deref()), |
| 1069 | optional(queued_at.as_deref()), | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1070 | optional(environment.as_deref()), |
| 1071 | optional(job_group.as_deref()), | |
| 1072 | u32::from(job_cancel).into(), | |
| The balance shows what was spent this month and in total | 1073 | ]; |
| 1074 | if index == 0 { | |
| 1075 | let mut bound = values; | |
| 1076 | bound.push(row.id.as_str().into()); | |
| 1077 | statements.push( | |
| 1078 | self.db | |
| 1079 | .prepare( | |
| 1080 | "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, timeout_minutes = ?, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1081 | continue_on_error = ?, max_parallel = ?, finished_at = ?, labels = ?, queued_at = ?, environment = ?, |
| 1082 | concurrency_group = ?, cancel_in_progress = ? WHERE id = ?", | |
| The balance shows what was spent this month and in total | 1083 | ) |
| 1084 | .bind(&bound)?, | |
| 1085 | ); | |
| 1086 | } else { | |
| 1087 | let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![ | |
| 1088 | new_id("job", now_ms()).into(), | |
| 1089 | row.run_id.as_str().into(), | |
| 1090 | row.repo_id.as_str().into(), | |
| 1091 | row.namespace.as_str().into(), | |
| 1092 | row.key.as_str().into(), | |
| 1093 | (index as u32).into(), | |
| 1094 | row.needs.as_str().into(), | |
| 1095 | optional(row.call.as_deref()), | |
| 1096 | ]; | |
| 1097 | bound.extend(values); | |
| 1098 | statements.push( | |
| 1099 | self.db | |
| 1100 | .prepare( | |
| 1101 | "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, call, name, matrix, status, conclusion, reason, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1102 | timeout_minutes, continue_on_error, max_parallel, finished_at, labels, queued_at, environment, |
| 1103 | concurrency_group, cancel_in_progress) | |
| 1104 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| The balance shows what was spent this month and in total | 1105 | ) |
| 1106 | .bind(&bound)?, | |
| 1107 | ); | |
| 1108 | } | |
| 1109 | } | |
| 1110 | self.db.batch(statements).await?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1111 | self.replace_in_groups(run, &row.key, &groups).await |
| 1112 | } | |
| 1113 | ||
| 1114 | /// A job queued in its own concurrency group: with `cancel-in-progress` | |
| 1115 | /// it cancels the group's other jobs; otherwise it replaces any that | |
| 1116 | /// are queued and not started, and waits for the one running | |
| 1117 | /// (`start_queued` starts one of a group at a time). As on GitHub. | |
| 1118 | async fn replace_in_groups(&self, run: &RunRow, key: &str, groups: &[(String, bool)]) -> Result<()> { | |
| 1119 | if groups.is_empty() { | |
| 1120 | return Ok(()); | |
| 1121 | } | |
| 1122 | #[derive(Deserialize)] | |
| 1123 | struct Id { | |
| 1124 | id: String, | |
| 1125 | } | |
| 1126 | let mine: Vec<String> = self | |
| 1127 | .db | |
| 1128 | .prepare("SELECT id FROM jobs WHERE run_id = ? AND key = ?") | |
| 1129 | .bind(&[run.id.as_str().into(), key.into()])? | |
| 1130 | .all() | |
| 1131 | .await? | |
| 1132 | .results::<Id>()? | |
| 1133 | .into_iter() | |
| 1134 | .map(|row| row.id) | |
| 1135 | .collect(); | |
| 1136 | let mut moved: Vec<String> = Vec::new(); | |
| 1137 | for (group, cancel) in groups { | |
| 1138 | let others = self | |
| 1139 | .db | |
| 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. | 1140 | // A call that waits in the group, or calls, is in it too. |
| 1141 | .prepare("SELECT * FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('waiting', 'queued', 'pending', 'in_progress', 'calling')") | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1142 | .bind(&[run.repo_id.as_str().into(), group.as_str().into()])? |
| 1143 | .all() | |
| 1144 | .await? | |
| 1145 | .results::<JobRow>()?; | |
| 1146 | for other in others.iter().filter(|other| !mine.contains(&other.id)) { | |
| 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. | 1147 | if *cancel || matches!(other.status.as_str(), "queued" | "waiting") { |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1148 | self.stop_job(other, "A newer job in the same concurrency group replaced it.").await?; |
| 1149 | if !moved.contains(&other.run_id) { | |
| 1150 | moved.push(other.run_id.clone()); | |
| 1151 | } | |
| 1152 | } | |
| 1153 | } | |
| 1154 | } | |
| 1155 | for other_run in moved.iter().filter(|id| **id != run.id) { | |
| 1156 | Box::pin(self.advance(other_run)).await?; | |
| 1157 | } | |
| The balance shows what was spent this month and in total | 1158 | Ok(()) |
| 1159 | } | |
| 1160 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1161 | /// A job that calls a reusable workflow, in the repository or another |
| 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. | 1162 | /// (reach.rs): once for each combination of its matrix, as on GitHub, |
| 1163 | /// each with the inputs and secrets it passes read with that | |
| 1164 | /// combination's `matrix`. Each call waits (`holds_call`) until its | |
| 1165 | /// matrix's `max-parallel` and its concurrency group have room, when | |
| 1166 | /// `start_call` puts the workflow's jobs in the run under it. | |
| 1167 | #[allow(clippy::too_many_arguments)] | |
| 1168 | async fn call_workflow( | |
| 1169 | &self, | |
| 1170 | run: &RunRow, | |
| 1171 | job: &workflow::Job, | |
| 1172 | row: &JobRow, | |
| 1173 | uses: &str, | |
| 1174 | contexts: &Map<String, Value>, | |
| 1175 | combinations: &[Map<String, Value>], | |
| 1176 | caller: Option<&str>, | |
| 1177 | ) -> Result<()> { | |
| The balance shows what was spent this month and in total | 1178 | let depth = row.call().and_then(|c| c["depth"].as_u64()).unwrap_or(0) + 1; |
| 1179 | if depth > MAX_CALL_DEPTH { | |
| 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. | 1180 | return self.fail_job(row, &call_allowed(depth, &[], uses).unwrap_err()).await; |
| The balance shows what was spent this month and in total | 1181 | } |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1182 | let (file, source, origin) = match self.called_workflow(run, row, uses).await? { |
| 1183 | Ok(found) => found, | |
| 1184 | Err(why) => return self.fail_job(row, &why).await, | |
| The balance shows what was spent this month and in total | 1185 | }; |
| 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. | 1186 | let jobs = self.job_rows(&run.id).await?; |
| 1187 | // Each call so far, by the workflow it called. | |
| 1188 | let called_paths: Vec<String> = jobs | |
| 1189 | .iter() | |
| 1190 | .filter(|other| other.call_prefix().is_some()) | |
| 1191 | .filter_map(JobRow::call) | |
| 1192 | .filter_map(|call| call["path"].as_str().map(str::to_owned)) | |
| 1193 | .collect(); | |
| 1194 | let called_already: Vec<&str> = called_paths.iter().map(String::as_str).collect(); | |
| 1195 | if let Err(why) = call_allowed(depth, &called_already, &file) { | |
| 1196 | return self.fail_job(row, &why).await; | |
| 1197 | } | |
| The balance shows what was spent this month and in total | 1198 | let called = match workflow::parse(&source) { |
| 1199 | Ok(called) => called, | |
| 1200 | Err(problem) => return self.fail_job(row, &format!("`{file}` does not read: {problem}")).await, | |
| 1201 | }; | |
| 1202 | let Some(trigger) = called.trigger("workflow_call") else { | |
| 1203 | return self.fail_job(row, &format!("`{file}` cannot be called: it has no `on: workflow_call`.")).await; | |
| 1204 | }; | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1205 | // Secrets: none but the job's token unless `secrets:` passes them, |
| 1206 | // by name or with `inherit`; read when each job starts (job_spec). | |
| 1207 | let outer = row.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned()); | |
| 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. | 1208 | let total = combinations.len(); |
| 1209 | let mut groups: Vec<(String, bool)> = Vec::new(); | |
| 1210 | let mut statements = Vec::new(); | |
| 1211 | for (index, combination) in combinations.iter().enumerate() { | |
| 1212 | let mut contexts = contexts.clone(); | |
| 1213 | contexts.insert("matrix".into(), Value::Object(combination.clone())); | |
| 1214 | contexts.insert( | |
| 1215 | "strategy".into(), | |
| 1216 | json!({ "fail-fast": job.fail_fast, "job-index": index, "job-total": total, "max-parallel": job.max_parallel.unwrap_or(total as u32) }), | |
| 1217 | ); | |
| 1218 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 1219 | let name = shown_name(job, caller, combination, &scope); | |
| 1220 | // Inputs: what `with:` passes, as the called workflow types them. | |
| 1221 | let given = match job.raw.get("with") { | |
| 1222 | Some(with) => match expr::interpolate_value(with, &scope) { | |
| 1223 | Ok(Value::Object(given)) => Ok(given), | |
| 1224 | Ok(_) => Ok(Map::new()), | |
| 1225 | Err(problem) => Err(format!("Its `with` does not read: {problem}")), | |
| 1226 | }, | |
| 1227 | None => Ok(Map::new()), | |
| 1228 | }; | |
| 1229 | let inputs = given.and_then(|given| crate::reach::call_inputs(&file, &trigger.inputs, &given)); | |
| 1230 | let secrets = crate::reach::secrets_plan(&job.raw, &contexts, outer.clone()); | |
| 1231 | let problem = match &inputs { | |
| 1232 | Err(why) => Some(why.clone()), | |
| 1233 | Ok(_) => crate::reach::missing_secrets(&called.raw, &secrets) | |
| 1234 | .first() | |
| 1235 | .map(|name| format!("`{file}` needs the secret `{name}`: pass it under `secrets:`, or use `secrets: inherit`.")) | |
| 1236 | .or_else(|| { | |
| 1237 | crate::reach::undeclared_secrets(&called.raw, &secrets) | |
| 1238 | .first() | |
| 1239 | .map(|name| format!("Invalid secret: `{name}` is not defined in `{file}`.")) | |
| 1240 | }), | |
| 1241 | }; | |
| 1242 | // Its own concurrency group, which it holds while its workflow runs. | |
| 1243 | let group = job | |
| 1244 | .concurrency | |
| 1245 | .as_ref() | |
| 1246 | .and_then(|c| expr::interpolate(&c.group, &scope).ok()) | |
| 1247 | .map(|group| group.trim().to_owned()) | |
| 1248 | .filter(|group| !group.is_empty()); | |
| 1249 | let cancel = job | |
| 1250 | .concurrency | |
| 1251 | .as_ref() | |
| 1252 | .and_then(|c| expr::interpolate_value(&c.cancel_in_progress, &scope).ok()) | |
| 1253 | .is_some_and(|value| expr::truthy(&value)); | |
| 1254 | if problem.is_none() | |
| 1255 | && let Some(group) = &group | |
| 1256 | && !groups.iter().any(|(known, _)| known == group) | |
| 1257 | { | |
| 1258 | groups.push((group.clone(), cancel)); | |
| 1259 | } | |
| 1260 | let call = json!({ | |
| 1261 | "role": "caller", "path": file, "source": source, "origin": origin, "depth": depth, | |
| 1262 | "prefix": call_prefix(&row.key, job.matrix.is_some().then_some(index)), "title": name, | |
| 1263 | "inputs": inputs.unwrap_or_default(), "secrets": secrets, "fail_fast": job.fail_fast, | |
| 1264 | }); | |
| 1265 | let (status, conclusion, finished) = match &problem { | |
| 1266 | Some(_) => ("completed", Some("failure"), Some(now())), | |
| 1267 | None => ("waiting", None, None), | |
| 1268 | }; | |
| 1269 | let values: Vec<worker::wasm_bindgen::JsValue> = vec![ | |
| 1270 | name.as_str().into(), | |
| 1271 | serde_json::to_string(combination)?.into(), | |
| 1272 | status.into(), | |
| 1273 | optional(conclusion), | |
| 1274 | optional(problem.as_deref()), | |
| 1275 | job.max_parallel.map(|max| max.max(1)).map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 1276 | optional(finished.as_deref()), | |
| 1277 | optional(group.as_deref()), | |
| 1278 | u32::from(cancel).into(), | |
| 1279 | serde_json::to_string(&call)?.into(), | |
| 1280 | ]; | |
| 1281 | if index == 0 { | |
| 1282 | let mut bound = values; | |
| 1283 | bound.push(row.id.as_str().into()); | |
| 1284 | statements.push( | |
| 1285 | self.db | |
| 1286 | .prepare( | |
| 1287 | "UPDATE jobs SET name = ?, matrix = ?, status = ?, conclusion = ?, reason = ?, max_parallel = ?, finished_at = ?, | |
| 1288 | concurrency_group = ?, cancel_in_progress = ?, call = ? WHERE id = ?", | |
| 1289 | ) | |
| 1290 | .bind(&bound)?, | |
| 1291 | ); | |
| 1292 | } else { | |
| 1293 | let mut bound: Vec<worker::wasm_bindgen::JsValue> = vec![ | |
| 1294 | new_id("job", now_ms()).into(), | |
| 1295 | row.run_id.as_str().into(), | |
| 1296 | row.repo_id.as_str().into(), | |
| 1297 | row.namespace.as_str().into(), | |
| 1298 | row.key.as_str().into(), | |
| 1299 | (index as u32).into(), | |
| 1300 | row.needs.as_str().into(), | |
| 1301 | ]; | |
| 1302 | bound.extend(values); | |
| 1303 | statements.push( | |
| 1304 | self.db | |
| 1305 | .prepare( | |
| 1306 | "INSERT INTO jobs (id, run_id, repo_id, namespace, key, ordinal, needs, name, matrix, status, conclusion, reason, | |
| 1307 | max_parallel, finished_at, concurrency_group, cancel_in_progress, call) | |
| 1308 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1309 | ) | |
| 1310 | .bind(&bound)?, | |
| 1311 | ); | |
| 1312 | } | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1313 | } |
| 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. | 1314 | self.db.batch(statements).await?; |
| 1315 | self.replace_in_groups(run, &row.key, &groups).await | |
| 1316 | } | |
| 1317 | ||
| 1318 | /// Whether another job of `row`'s concurrency group runs, or calls a | |
| 1319 | /// workflow, so that `row` waits for it. | |
| 1320 | async fn group_busy(&self, row: &JobRow) -> Result<bool> { | |
| 1321 | let Some(group) = &row.concurrency_group else { return Ok(false) }; | |
| 1322 | let busy = self | |
| 1323 | .db | |
| 1324 | .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?") | |
| 1325 | .bind(&[row.repo_id.as_str().into(), group.as_str().into(), row.id.as_str().into()])? | |
| 1326 | .first::<Count>(None) | |
| 1327 | .await? | |
| 1328 | .map_or(0, |count| count.n); | |
| 1329 | Ok(busy > 0) | |
| 1330 | } | |
| 1331 | ||
| 1332 | /// A call that waited (`holds_call`) is made: the called workflow's | |
| 1333 | /// jobs join the run under it, named under its name and keyed under its | |
| 1334 | /// prefix. False when it no longer waits. | |
| 1335 | async fn start_call(&self, row: &JobRow) -> Result<bool> { | |
| 1336 | let Some(call) = row.call() else { return Ok(false) }; | |
| 1337 | let Some(prefix) = row.call_prefix() else { return Ok(false) }; | |
| 1338 | let Some(called) = call["source"].as_str().and_then(|source| workflow::parse(source).ok()) else { return Ok(false) }; | |
| 1339 | let file = call["path"].as_str().unwrap_or_default(); | |
| 1340 | let started = self | |
| 1341 | .db | |
| 1342 | .prepare("UPDATE jobs SET status = 'calling', reason = ?, started_at = ? WHERE id = ? AND status = 'waiting' RETURNING id") | |
| 1343 | .bind(&[format!("Calls `{file}`.").into(), now().into(), row.id.as_str().into()])? | |
| 1344 | .first::<Value>(None) | |
| 1345 | .await?; | |
| 1346 | if started.is_none() { | |
| 1347 | return Ok(false); | |
| 1348 | } | |
| The balance shows what was spent this month and in total | 1349 | let mut statements = Vec::new(); |
| 1350 | for called_job in &called.jobs { | |
| 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. | 1351 | let needs: Vec<String> = called_job.needs.iter().map(|n| format!("{prefix}/{n}")).collect(); |
| 1352 | let callee = json!({ | |
| 1353 | "role": "callee", "parent": prefix, "title": row.name, "job": called_job.id, "path": file, | |
| 1354 | "source": call["source"], "inputs": call["inputs"], "depth": call["depth"], "origin": call["origin"], "secrets": call["secrets"], | |
| The balance shows what was spent this month and in total | 1355 | }); |
| 1356 | statements.push( | |
| 1357 | self.db | |
| 1358 | .prepare("INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, needs, status, call) VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?)") | |
| 1359 | .bind(&[ | |
| 1360 | new_id("job", now_ms()).into(), | |
| 1361 | row.run_id.as_str().into(), | |
| 1362 | row.repo_id.as_str().into(), | |
| 1363 | row.namespace.as_str().into(), | |
| 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. | 1364 | format!("{prefix}/{}", called_job.id).into(), |
| The balance shows what was spent this month and in total | 1365 | format!("{} / {}", row.name, called_job.name.clone().unwrap_or(called_job.id.clone())).into(), |
| 1366 | serde_json::to_string(&needs)?.into(), | |
| 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. | 1367 | serde_json::to_string(&callee)?.into(), |
| The balance shows what was spent this month and in total | 1368 | ])?, |
| 1369 | ); | |
| 1370 | } | |
| 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. | 1371 | if !statements.is_empty() { |
| 1372 | self.db.batch(statements).await?; | |
| 1373 | } | |
| 1374 | Ok(true) | |
| 1375 | } | |
| 1376 | ||
| 1377 | /// The runs with a call waiting in `group`, but `run_id`, moved along | |
| 1378 | /// now that a job of the group has finished. | |
| 1379 | async fn wake_group(&self, repo_id: &str, group: &str, run_id: &str) -> Result<()> { | |
| 1380 | #[derive(Deserialize)] | |
| 1381 | struct RunId { | |
| 1382 | run_id: String, | |
| 1383 | } | |
| 1384 | let waiting = self | |
| 1385 | .db | |
| 1386 | .prepare("SELECT DISTINCT run_id FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status = 'waiting' AND run_id != ?") | |
| 1387 | .bind(&[repo_id.into(), group.into(), run_id.into()])? | |
| 1388 | .all() | |
| 1389 | .await? | |
| 1390 | .results::<RunId>()?; | |
| 1391 | for other in waiting { | |
| 1392 | Box::pin(self.advance(&other.run_id)).await?; | |
| 1393 | } | |
| The balance shows what was spent this month and in total | 1394 | Ok(()) |
| 1395 | } | |
| 1396 | ||
| 1397 | /// A job that called a workflow, finished with its jobs: their result, | |
| 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. | 1398 | /// and the outputs the workflow declares. A failed call of a fail-fast |
| 1399 | /// matrix stops the matrix's other calls. | |
| 1400 | async fn finish_call(&self, run: &RunRow, row: &JobRow, children: &[&JobRow]) -> Result<()> { | |
| The balance shows what was spent this month and in total | 1401 | let call = row.call().unwrap_or_default(); |
| 1402 | let called = call["source"].as_str().and_then(|s| workflow::parse(s).ok()); | |
| 1403 | let mut jobs_context = Map::new(); | |
| 1404 | let mut by_key: std::collections::BTreeMap<String, Vec<&JobRow>> = std::collections::BTreeMap::new(); | |
| 1405 | for child in children { | |
| 1406 | by_key.entry(child.key.clone()).or_default().push(child); | |
| 1407 | } | |
| 1408 | for (key, rows) in &by_key { | |
| 1409 | let mut outputs = Map::new(); | |
| 1410 | for child in rows { | |
| 1411 | if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&child.outputs) { | |
| 1412 | outputs.extend(more); | |
| 1413 | } | |
| 1414 | } | |
| 1415 | let id = key.rsplit('/').next().unwrap_or(key); | |
| 1416 | jobs_context.insert(id.to_owned(), json!({ "result": key_result(rows), "outputs": outputs })); | |
| 1417 | } | |
| 1418 | let inputs = children.first().and_then(|c| c.call()).map(|c| c["inputs"].clone()).unwrap_or(json!({})); | |
| 1419 | let mut contexts = Map::new(); | |
| 1420 | contexts.insert("jobs".into(), Value::Object(jobs_context)); | |
| 1421 | contexts.insert("inputs".into(), inputs); | |
| 1422 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 1423 | let mut outputs = Map::new(); | |
| 1424 | if let Some(Value::Object(declared)) = called.as_ref().map(|w| { | |
| 1425 | let on = w.raw.get("on").or_else(|| w.raw.get("true")).cloned().unwrap_or(Value::Null); | |
| 1426 | on.get("workflow_call").and_then(|c| c.get("outputs")).cloned().unwrap_or(Value::Null) | |
| 1427 | }) { | |
| 1428 | for (name, spec) in declared { | |
| 1429 | if let Some(value) = spec.get("value") { | |
| 1430 | let value = expr::interpolate_value(value, &scope).unwrap_or(Value::Null); | |
| 1431 | outputs.insert(name, Value::String(expr::to_text(&value))); | |
| 1432 | } | |
| 1433 | } | |
| 1434 | } | |
| 1435 | let conclusion = key_result(children); | |
| 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. | 1436 | let finished = self |
| 1437 | .db | |
| 1438 | .prepare("UPDATE jobs SET status = 'completed', conclusion = ?, outputs = ?, finished_at = ? WHERE id = ? AND status = 'calling' RETURNING id") | |
| The balance shows what was spent this month and in total | 1439 | .bind(&[conclusion.into(), serde_json::to_string(&outputs)?.into(), now().into(), row.id.as_str().into()])? |
| 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. | 1440 | .first::<Value>(None) |
| The balance shows what was spent this month and in total | 1441 | .await?; |
| 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. | 1442 | if finished.is_none() { |
| 1443 | return Ok(()); | |
| 1444 | } | |
| 1445 | if conclusion == "failure" && row.matrix.as_deref().is_some_and(|m| m != "{}") && call["fail_fast"] != json!(false) { | |
| 1446 | let siblings = self | |
| 1447 | .db | |
| 1448 | .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'") | |
| 1449 | .bind(&[row.run_id.as_str().into(), row.key.as_str().into()])? | |
| 1450 | .all() | |
| 1451 | .await? | |
| 1452 | .results::<JobRow>()?; | |
| 1453 | for sibling in siblings { | |
| 1454 | self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?; | |
| 1455 | } | |
| 1456 | } | |
| 1457 | if let Some(group) = &row.concurrency_group { | |
| 1458 | self.wake_group(&run.repo_id, group, &run.id).await?; | |
| 1459 | } | |
| The balance shows what was spent this month and in total | 1460 | Ok(()) |
| 1461 | } | |
| 1462 | ||
| 1463 | /// A file's text at a commit, if it is there. | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1464 | pub(crate) async fn read_file(&self, path: &RepoPath, ws: &g1t_contracts::User, sha: &str, file: &str) -> Result<Option<String>> { |
| The balance shows what was spent this month and in total | 1465 | let blob: Outcome<g1t_contracts::repos::BlobView> = g1t_kit::call( |
| 1466 | &self.repos, | |
| 1467 | "blob", | |
| 1468 | &g1t_contracts::repos::BlobArgs { | |
| 1469 | path: path.clone(), | |
| 1470 | viewer: Some(ws.clone()), | |
| 1471 | git_ref: sha.to_owned(), | |
| 1472 | file_path: file.to_owned(), | |
| 1473 | }, | |
| 1474 | ) | |
| 1475 | .await?; | |
| 1476 | Ok(match blob { | |
| 1477 | Outcome::Ok(view) => view.text, | |
| 1478 | Outcome::Fail(_) => None, | |
| 1479 | }) | |
| 1480 | } | |
| 1481 | ||
| 1482 | async fn skip_job(&self, row: &JobRow, reason: Option<&str>) -> Result<()> { | |
| 1483 | self.db | |
| 1484 | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'skipped', reason = ?, finished_at = ? WHERE id = ?") | |
| 1485 | .bind(&[optional(reason), now().into(), row.id.as_str().into()])? | |
| 1486 | .run() | |
| 1487 | .await?; | |
| 1488 | Ok(()) | |
| 1489 | } | |
| 1490 | ||
| 1491 | async fn fail_job(&self, row: &JobRow, reason: &str) -> Result<()> { | |
| 1492 | self.db | |
| 1493 | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ? WHERE id = ? AND status != 'completed'") | |
| 1494 | .bind(&[reason.into(), now().into(), row.id.as_str().into()])? | |
| 1495 | .run() | |
| 1496 | .await?; | |
| 1497 | Ok(()) | |
| 1498 | } | |
| 1499 | ||
| 1500 | /// Starts queued jobs, oldest first, while their workspace has room. | |
| 1501 | pub async fn start_queued(&self) -> Result<()> { | |
| 1502 | let queued = self | |
| 1503 | .db | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1504 | // Self-hosted jobs are taken by their runners (runners.rs). |
| 1505 | .prepare("SELECT * FROM jobs WHERE status = 'queued' AND labels IS NULL ORDER BY rowid LIMIT 50") | |
| The balance shows what was spent this month and in total | 1506 | .all() |
| 1507 | .await? | |
| 1508 | .results::<JobRow>()?; | |
| 1509 | let mut running: std::collections::HashMap<String, u32> = std::collections::HashMap::new(); | |
| 1510 | for job in queued { | |
| 1511 | let in_workspace = match running.get(&job.namespace) { | |
| 1512 | Some(n) => *n, | |
| 1513 | None => { | |
| 1514 | let n = self | |
| 1515 | .db | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1516 | // Only g1t's own sandboxes count against the workspace's room. |
| 1517 | .prepare("SELECT COUNT(*) AS n FROM jobs WHERE status = 'in_progress' AND namespace = ? AND runner_id IS NULL") | |
| The balance shows what was spent this month and in total | 1518 | .bind(&[job.namespace.as_str().into()])? |
| 1519 | .first::<Count>(None) | |
| 1520 | .await? | |
| 1521 | .map_or(0, |count| count.n); | |
| 1522 | running.insert(job.namespace.clone(), n); | |
| 1523 | n | |
| 1524 | } | |
| 1525 | }; | |
| 1526 | if in_workspace >= RUNNING_PER_WORKSPACE { | |
| 1527 | continue; | |
| 1528 | } | |
| 1529 | if let Some(max) = job.max_parallel { | |
| 1530 | let siblings = self | |
| 1531 | .db | |
| 1532 | .prepare("SELECT COUNT(*) AS n FROM jobs WHERE run_id = ? AND key = ? AND status = 'in_progress'") | |
| 1533 | .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])? | |
| 1534 | .first::<Count>(None) | |
| 1535 | .await? | |
| 1536 | .map_or(0, |count| count.n); | |
| 1537 | if siblings >= max { | |
| 1538 | continue; | |
| 1539 | } | |
| 1540 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1541 | // One job of a concurrency group runs at a time. |
| 1542 | if let Some(group) = &job.concurrency_group { | |
| 1543 | let running = self | |
| 1544 | .db | |
| 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. | 1545 | .prepare("SELECT COUNT(*) AS n FROM jobs WHERE repo_id = ? AND concurrency_group = ? AND status IN ('in_progress', 'calling') AND id != ?") |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1546 | .bind(&[job.repo_id.as_str().into(), group.as_str().into(), job.id.as_str().into()])? |
| 1547 | .first::<Count>(None) | |
| 1548 | .await? | |
| 1549 | .map_or(0, |count| count.n); | |
| 1550 | if running > 0 { | |
| 1551 | continue; | |
| 1552 | } | |
| 1553 | } | |
| The balance shows what was spent this month and in total | 1554 | let token = random_hex(24); |
| 1555 | let at = now(); | |
| 1556 | let claimed = self | |
| 1557 | .db | |
| 1558 | .prepare( | |
| 1559 | "UPDATE jobs SET status = 'in_progress', token_hash = ?, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id", | |
| 1560 | ) | |
| 1561 | .bind(&[sha256_hex(&token).into(), at.as_str().into(), at.as_str().into(), job.id.as_str().into()])? | |
| 1562 | .first::<Value>(None) | |
| 1563 | .await?; | |
| 1564 | if claimed.is_none() { | |
| 1565 | continue; | |
| 1566 | } | |
| 1567 | running.insert(job.namespace.clone(), in_workspace + 1); | |
| 1568 | self.db | |
| 1569 | .prepare("UPDATE runs SET status = 'in_progress', started_at = COALESCE(started_at, ?) WHERE id = ? AND status = 'queued'") | |
| 1570 | .bind(&[at.as_str().into(), job.run_id.as_str().into()])? | |
| 1571 | .run() | |
| 1572 | .await?; | |
| 1573 | let run = self.run_row(&job.run_id).await?; | |
| 1574 | let repo: RepoPath = run.as_ref().map(|run| repo_path(&run.repo)).unwrap_or(RepoPath { | |
| 1575 | namespace: job.namespace.clone(), | |
| 1576 | name: String::new(), | |
| 1577 | }); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1578 | // Its environment and the machine it asked for, for the runner. |
| 1579 | let details = run.as_ref().map(|run| start_details(run, &job)).unwrap_or_default(); | |
| The balance shows what was spent this month and in total | 1580 | let started: Outcome<Value> = g1t_kit::call( |
| 1581 | &self.runner, | |
| 1582 | "start_actions_job", | |
| 1583 | &StartJobArgs { | |
| 1584 | job: job.id.clone(), | |
| 1585 | token, | |
| 1586 | repo, | |
| 1587 | timeout_minutes: job.timeout_minutes, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1588 | workflow: run.as_ref().map(|run| run.path.clone()), |
| 1589 | environment: details.environment, | |
| 1590 | trusted: run.as_ref().is_some_and(|run| run.trusted != 0), | |
| 1591 | instance: details.instance, | |
| The balance shows what was spent this month and in total | 1592 | }, |
| 1593 | ) | |
| 1594 | .await | |
| 1595 | .unwrap_or_else(|error| fail(FailureCode::Conflict, format!("The runner could not be reached: {error}"))); | |
| 1596 | if let Outcome::Fail(refused) = started { | |
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 1597 | // Billing would not pay for a scheduled run's job: the |
| 1598 | // schedule waits rather than making runs only to refuse them. | |
| 1599 | if refused.code == FailureCode::PaymentRequired | |
| 1600 | && let Some(run) = run.as_ref().filter(|run| run.event == "schedule") | |
| 1601 | { | |
| 1602 | self.db | |
| 1603 | .prepare("UPDATE workflows SET schedule_refused_until = ? WHERE id = ?") | |
| 1604 | .bind(&[rfc3339(now_ms() + crate::SCHEDULE_REFUSED_MS).into(), run.workflow_id.as_str().into()])? | |
| 1605 | .run() | |
| 1606 | .await?; | |
| 1607 | } | |
| The balance shows what was spent this month and in total | 1608 | Box::pin(self.finish_job(&job.id, "failure", Some(&refused.message), None)).await?; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1609 | } else { |
| 1610 | self.job_started(&job.id).await?; | |
| The balance shows what was spent this month and in total | 1611 | } |
| 1612 | } | |
| 1613 | Ok(()) | |
| 1614 | } | |
| 1615 | ||
| 1616 | /// Finishes a job and moves its run along. | |
| 1617 | pub async fn finish_job(&self, job_id: &str, conclusion: &str, reason: Option<&str>, outputs: Option<&Map<String, Value>>) -> Result<()> { | |
| 1618 | let finished = self | |
| 1619 | .db | |
| 1620 | .prepare( | |
| 1621 | "UPDATE jobs SET status = 'completed', conclusion = ?, reason = COALESCE(?, reason), outputs = COALESCE(?, outputs), | |
| 1622 | finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *", | |
| 1623 | ) | |
| 1624 | .bind(&[ | |
| 1625 | conclusion.into(), | |
| 1626 | optional(reason), | |
| 1627 | outputs.map(|o| serde_json::to_string(o).unwrap_or_default()).as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 1628 | now().into(), | |
| 1629 | job_id.into(), | |
| 1630 | ])? | |
| 1631 | .first::<JobRow>(None) | |
| 1632 | .await?; | |
| 1633 | let Some(job) = finished else { return Ok(()) }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1634 | // Its G1T_TOKEN stops working with it. |
| 1635 | self.revoke_job_tokens(&job.id).await; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1636 | // A job that deploys failed: so did its run's deployment, now. |
| 1637 | if conclusion == "failure" && job.continue_on_error == 0 && job.started_at.is_some() | |
| 1638 | && let Some(run) = self.run_row(&job.run_id).await? | |
| 1639 | && let Some(env) = deploys_to(&run, &job) | |
| 1640 | { | |
| 1641 | self.report_deployment(&run, &env, "failure", false).await; | |
| 1642 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1643 | // A self-hosted runner's job: the runner is free again, and its time |
| 1644 | // is recorded, at nothing. | |
| 1645 | if job.runner_id.is_some() { | |
| 1646 | self.released(&job).await?; | |
| 1647 | } | |
| The balance shows what was spent this month and in total | 1648 | // Steps still marked as going are not going any more. |
| 1649 | let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default(); | |
| 1650 | let mut touched = false; | |
| 1651 | for step in steps.iter_mut() { | |
| 1652 | if step["status"] != "completed" { | |
| 1653 | let was_running = step["status"] == "in_progress"; | |
| 1654 | step["status"] = json!("completed"); | |
| 1655 | step["conclusion"] = json!(if was_running { conclusion } else { "skipped" }); | |
| 1656 | touched = true; | |
| 1657 | } | |
| 1658 | } | |
| 1659 | if touched { | |
| 1660 | self.db | |
| 1661 | .prepare("UPDATE jobs SET steps = ? WHERE id = ?") | |
| 1662 | .bind(&[serde_json::to_string(&steps)?.into(), job.id.as_str().into()])? | |
| 1663 | .run() | |
| 1664 | .await?; | |
| 1665 | } | |
| 1666 | // fail-fast: one failed combination stops the rest of its matrix. | |
| 1667 | if conclusion == "failure" && job.continue_on_error == 0 && job.matrix.as_deref().is_some_and(|m| m != "{}") { | |
| 1668 | let run = self.run_row(&job.run_id).await?; | |
| 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. | 1669 | // As its workflow, or the workflow it is a job of, says. |
| 1670 | let fail_fast = run.as_ref().and_then(|run| job_spec(run, &job)).is_none_or(|j| j.fail_fast); | |
| The balance shows what was spent this month and in total | 1671 | if fail_fast { |
| 1672 | let siblings = self | |
| 1673 | .db | |
| 1674 | .prepare("SELECT * FROM jobs WHERE run_id = ? AND key = ? AND status != 'completed'") | |
| 1675 | .bind(&[job.run_id.as_str().into(), job.key.as_str().into()])? | |
| 1676 | .all() | |
| 1677 | .await? | |
| 1678 | .results::<JobRow>()?; | |
| 1679 | for sibling in siblings { | |
| 1680 | self.stop_job(&sibling, "Another job of its matrix failed, and the matrix is fail-fast.").await?; | |
| 1681 | } | |
| 1682 | } | |
| 1683 | } | |
| 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. | 1684 | // A call waiting in its concurrency group, in another run, may start. |
| 1685 | if let Some(group) = &job.concurrency_group { | |
| 1686 | self.wake_group(&job.repo_id, group, &job.run_id).await?; | |
| 1687 | } | |
| The balance shows what was spent this month and in total | 1688 | Box::pin(self.advance(&job.run_id)).await |
| 1689 | } | |
| 1690 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 1691 | /// Cancels a job. One that is running is told to stop (the answer to |
| 1692 | /// its next report), and runs its `if: always()` and `cancelled()` | |
| 1693 | /// steps and its post steps before it ends `cancelled`; one that has | |
| 1694 | /// not started is cancelled at once. | |
| The balance shows what was spent this month and in total | 1695 | async fn stop_job(&self, job: &JobRow, reason: &str) -> Result<()> { |
| 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. | 1696 | // A job calling a workflow stops with that workflow's jobs. |
| 1697 | if job.status == "calling" | |
| 1698 | && let Some(prefix) = job.call_prefix() | |
| 1699 | { | |
| 1700 | let under = format!("{prefix}/"); | |
| 1701 | for child in self.job_rows(&job.run_id).await?.iter().filter(|child| child.key.starts_with(&under) && child.status != "completed") { | |
| 1702 | Box::pin(self.stop_job(child, reason)).await?; | |
| 1703 | } | |
| 1704 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 1705 | if job.status == "in_progress" && job.started_at.is_some() { |
| 1706 | self.db | |
| 1707 | .prepare("UPDATE jobs SET cancel_requested_at = COALESCE(cancel_requested_at, ?), reason = ? WHERE id = ? AND status = 'in_progress'") | |
| 1708 | .bind(&[now().into(), reason.into(), job.id.as_str().into()])? | |
| 1709 | .run() | |
| 1710 | .await?; | |
| 1711 | return Ok(()); | |
| 1712 | } | |
| 1713 | self.hard_stop(job, reason).await | |
| 1714 | } | |
| 1715 | ||
| 1716 | /// Cancels a job outright, stopping its sandbox if it has one. | |
| 1717 | async fn hard_stop(&self, job: &JobRow, reason: &str) -> Result<()> { | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1718 | // A self-hosted runner hears it was cancelled on its next poll. |
| 1719 | if job.status == "in_progress" && job.runner_id.is_none() { | |
| The balance shows what was spent this month and in total | 1720 | let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await; |
| 1721 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1722 | let stopped = self |
| 1723 | .db | |
| 1724 | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *") | |
| The balance shows what was spent this month and in total | 1725 | .bind(&[reason.into(), now().into(), job.id.as_str().into()])? |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1726 | .first::<JobRow>(None) |
| The balance shows what was spent this month and in total | 1727 | .await?; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1728 | if stopped.as_ref().is_some_and(|row| row.started_at.is_some()) { |
| 1729 | self.revoke_job_tokens(&job.id).await; | |
| 1730 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1731 | if let Some(stopped) = stopped.filter(|row| row.runner_id.is_some()) { |
| 1732 | self.released(&stopped).await?; | |
| 1733 | } | |
| The balance shows what was spent this month and in total | 1734 | Ok(()) |
| 1735 | } | |
| 1736 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1737 | /// Ends a job's tokens at once. A failure is logged: the token expires |
| 1738 | /// on its own soon after the job's time limit. | |
| 1739 | async fn revoke_job_tokens(&self, job_id: &str) { | |
| 1740 | let revoked: Result<bool> = | |
| 1741 | g1t_kit::call(&self.identity, "revoke_job_tokens", &RevokeJobTokensArgs { job_id: job_id.to_owned() }).await; | |
| 1742 | if let Err(error) = revoked { | |
| 1743 | worker::console_error!("actions: the tokens of job {job_id} were not revoked: {error}"); | |
| 1744 | } | |
| 1745 | } | |
| 1746 | ||
| The balance shows what was spent this month and in total | 1747 | /// Finishes the run when every job has. |
| 1748 | async fn finish_if_done(&self, run_id: &str) -> Result<()> { | |
| 1749 | let Some(run) = self.run_row(run_id).await? else { return Ok(()) }; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1750 | if matches!(run.status.as_str(), "completed" | "pending" | "action_required") { |
| The balance shows what was spent this month and in total | 1751 | return Ok(()); |
| 1752 | } | |
| 1753 | let jobs = self.job_rows(run_id).await?; | |
| 1754 | if !jobs.iter().all(|job| job.status == "completed") { | |
| 1755 | return Ok(()); | |
| 1756 | } | |
| 1757 | self.finish_run(&run, None).await | |
| 1758 | } | |
| 1759 | ||
| 1760 | async fn finish_run(&self, run: &RunRow, error: Option<&str>) -> Result<()> { | |
| 1761 | let jobs = self.job_rows(&run.id).await?; | |
| 1762 | let rows: Vec<&JobRow> = jobs.iter().collect(); | |
| 1763 | let conclusion = if error.is_some() { | |
| 1764 | "failure" | |
| 1765 | } else if run.conclusion.as_deref() == Some("cancelled") { | |
| 1766 | "cancelled" | |
| 1767 | } else if rows.is_empty() { | |
| 1768 | "skipped" | |
| 1769 | } else { | |
| 1770 | key_result(&rows) | |
| 1771 | }; | |
| 1772 | let done = self | |
| 1773 | .db | |
| 1774 | .prepare("UPDATE runs SET status = 'completed', conclusion = ?, error = COALESCE(?, error), finished_at = ? WHERE id = ? AND status != 'completed' RETURNING id") | |
| 1775 | .bind(&[conclusion.into(), optional(error), now().into(), run.id.as_str().into()])? | |
| 1776 | .first::<Value>(None) | |
| 1777 | .await?; | |
| 1778 | if done.is_none() { | |
| 1779 | return Ok(()); | |
| 1780 | } | |
| 1781 | self.report_status(run, conclusion).await?; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1782 | self.settle_deployments(run, &jobs, conclusion == "cancelled").await; |
| The balance shows what was spent this month and in total | 1783 | let published: Result<()> = g1t_kit::call( |
| 1784 | &self.events, | |
| 1785 | "publish", | |
| 1786 | &g1t_contracts::events::Publish { | |
| 1787 | events: vec![g1t_contracts::events::NewEvent { | |
| 1788 | kind: "workflow.completed", | |
| 1789 | source: "actions", | |
| 1790 | repo_id: Some(run.repo_id.clone()), | |
| 1791 | actor: run.actor_id.clone(), | |
| 1792 | data: g1t_contracts::events::WorkflowEvent { | |
| 1793 | run_id: run.id.clone(), | |
| 1794 | repo_id: run.repo_id.clone(), | |
| 1795 | workflow: run.name.clone(), | |
| 1796 | path: run.path.clone(), | |
| 1797 | number: run.number, | |
| 1798 | event: run.event.clone(), | |
| 1799 | conclusion: conclusion.to_owned(), | |
| 1800 | git_ref: run.git_ref.clone(), | |
| 1801 | sha: run.sha.clone(), | |
| 1802 | pull: run.pull, | |
| 1803 | }, | |
| 1804 | }], | |
| 1805 | }, | |
| 1806 | ) | |
| 1807 | .await; | |
| 1808 | if let Err(error) = published { | |
| 1809 | worker::console_error!("actions: could not publish workflow.completed: {error}"); | |
| 1810 | } | |
| 1811 | // The next run waiting in its concurrency group. | |
| 1812 | if let Some(group) = &run.concurrency_group { | |
| 1813 | let next = self | |
| 1814 | .db | |
| 1815 | .prepare("SELECT * FROM runs WHERE repo_id = ? AND concurrency_group = ? AND status = 'pending' ORDER BY id LIMIT 1") | |
| 1816 | .bind(&[run.repo_id.as_str().into(), group.as_str().into()])? | |
| 1817 | .first::<RunRow>(None) | |
| 1818 | .await?; | |
| 1819 | if let Some(next) = next { | |
| 1820 | self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[next.id.as_str().into()])?.run().await?; | |
| 1821 | Box::pin(self.advance(&next.id)).await?; | |
| 1822 | } | |
| 1823 | } | |
| 1824 | Ok(()) | |
| 1825 | } | |
| 1826 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 1827 | /// Tells the deployments service how a run's deployment to `env` stands: |
| 1828 | /// made when its first job naming the environment starts, failed when one | |
| 1829 | /// of them fails, and settled (`last`) when the run finishes. Never fails | |
| 1830 | /// the run: a deployment that could not be recorded is logged. | |
| 1831 | async fn report_deployment(&self, run: &RunRow, env: &JobEnvironment, state: &str, last: bool) { | |
| 1832 | let path = repo_path(&run.repo); | |
| 1833 | let reported: Result<Value> = g1t_kit::call( | |
| 1834 | &self.deployments, | |
| 1835 | "actions_deployment", | |
| 1836 | &json!({ | |
| 1837 | "repoId": run.repo_id, | |
| 1838 | "repo": { "namespace": path.namespace, "name": path.name }, | |
| 1839 | "runId": run.id, | |
| 1840 | "attempt": run.attempt, | |
| 1841 | "runUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id), | |
| 1842 | "environment": env.name, | |
| 1843 | "url": env.url, | |
| 1844 | "ref": run.git_ref, | |
| 1845 | "sha": run.sha, | |
| 1846 | "state": state, | |
| 1847 | "final": last, | |
| 1848 | "creator": run.actor, | |
| 1849 | "workflow": run.name, | |
| 1850 | }), | |
| 1851 | ) | |
| 1852 | .await; | |
| 1853 | if let Err(error) = reported { | |
| 1854 | worker::console_error!("actions: deployment to {} not recorded for run {}: {error}", env.name, run.id); | |
| 1855 | } | |
| 1856 | } | |
| 1857 | ||
| 1858 | /// A job that deploys has started: its run's deployment to the | |
| 1859 | /// environment is under way. | |
| 1860 | pub(crate) async fn job_started(&self, job_id: &str) -> Result<()> { | |
| 1861 | let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job_id.into()])?.first::<JobRow>(None).await? else { | |
| 1862 | return Ok(()); | |
| 1863 | }; | |
| 1864 | let Some(run) = self.run_row(&job.run_id).await? else { return Ok(()) }; | |
| 1865 | if let Some(env) = deploys_to(&run, &job) { | |
| 1866 | self.report_deployment(&run, &env, "in_progress", false).await; | |
| 1867 | } | |
| 1868 | Ok(()) | |
| 1869 | } | |
| 1870 | ||
| 1871 | /// The run is over: each environment its jobs deployed to takes the | |
| 1872 | /// outcome of those jobs (`deployment_outcome`). | |
| 1873 | async fn settle_deployments(&self, run: &RunRow, jobs: &[JobRow], cancelled: bool) { | |
| 1874 | let mut seen: Vec<(JobEnvironment, Vec<Option<String>>)> = Vec::new(); | |
| 1875 | for job in jobs { | |
| 1876 | let Some(env) = deploys_to(run, job) else { continue }; | |
| 1877 | let conclusion = if cancelled && job.conclusion.as_deref() != Some("skipped") && job.started_at.is_some() { | |
| 1878 | Some("cancelled".to_owned()) | |
| 1879 | } else if job.started_at.is_none() { | |
| 1880 | Some("skipped".to_owned()) | |
| 1881 | } else { | |
| 1882 | job.conclusion.clone() | |
| 1883 | }; | |
| 1884 | match seen.iter_mut().find(|(known, _)| known.name.eq_ignore_ascii_case(&env.name)) { | |
| 1885 | Some((known, conclusions)) => { | |
| 1886 | if known.url.is_none() { | |
| 1887 | known.url = env.url.clone(); | |
| 1888 | } | |
| 1889 | conclusions.push(conclusion); | |
| 1890 | } | |
| 1891 | None => seen.push((env, vec![conclusion])), | |
| 1892 | } | |
| 1893 | } | |
| 1894 | for (env, conclusions) in seen { | |
| 1895 | if let Some(state) = deployment_outcome(&conclusions) { | |
| 1896 | self.report_deployment(run, &env, state, true).await; | |
| 1897 | } | |
| 1898 | } | |
| 1899 | } | |
| 1900 | ||
| The balance shows what was spent this month and in total | 1901 | /// Tells the pull request (or commit) how the run went, as a status. |
| 1902 | async fn report_status(&self, run: &RunRow, conclusion: &str) -> Result<()> { | |
| 1903 | let state = match conclusion { | |
| 1904 | "success" | "skipped" => "success", | |
| 1905 | "cancelled" => "error", | |
| 1906 | _ => "failure", | |
| 1907 | }; | |
| 1908 | let _: Result<Value> = g1t_kit::call( | |
| 1909 | &self.work, | |
| 1910 | "set_commit_status", | |
| 1911 | &json!({ | |
| 1912 | "repoId": run.repo_id, | |
| 1913 | "sha": run.sha, | |
| 1914 | "context": format!("{} / {}", run.name, run.event), | |
| 1915 | "state": state, | |
| 1916 | "description": format!("{} {}", run.name, match conclusion { | |
| 1917 | "success" => "passed", | |
| 1918 | "skipped" => "was skipped", | |
| 1919 | "cancelled" => "was cancelled", | |
| 1920 | _ => "failed", | |
| 1921 | }), | |
| 1922 | "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id), | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1923 | "source": "actions", |
| The balance shows what was spent this month and in total | 1924 | }), |
| 1925 | ) | |
| 1926 | .await; | |
| 1927 | Ok(()) | |
| 1928 | } | |
| 1929 | ||
| 1930 | /// Tells the pull request a run has started on its head. | |
| 1931 | pub async fn report_pending(&self, run: &RunRow) -> Result<()> { | |
| 1932 | let _: Result<Value> = g1t_kit::call( | |
| 1933 | &self.work, | |
| 1934 | "set_commit_status", | |
| 1935 | &json!({ | |
| 1936 | "repoId": run.repo_id, | |
| 1937 | "sha": run.sha, | |
| 1938 | "context": format!("{} / {}", run.name, run.event), | |
| 1939 | "state": "pending", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1940 | "description": if run.status == "action_required" { |
| 1941 | format!("{} is waiting for approval", run.name) | |
| 1942 | } else { | |
| 1943 | format!("{} is running", run.name) | |
| 1944 | }, | |
| The balance shows what was spent this month and in total | 1945 | "targetUrl": format!("{SITE}/{}/actions/runs/{}", run.repo, run.id), |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1946 | "source": "actions", |
| The balance shows what was spent this month and in total | 1947 | }), |
| 1948 | ) | |
| 1949 | .await; | |
| 1950 | Ok(()) | |
| 1951 | } | |
| 1952 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 1953 | /// Cancels a run: its waiting and queued jobs at once, and its running |
| 1954 | /// ones gracefully (`stop_job`), or outright when `force`. | |
| 1955 | pub async fn cancel_run(&self, run: &RunRow, reason: &str, force: bool) -> Result<()> { | |
| The balance shows what was spent this month and in total | 1956 | self.db |
| 1957 | .prepare("UPDATE runs SET conclusion = 'cancelled' WHERE id = ? AND status != 'completed'") | |
| 1958 | .bind(&[run.id.as_str().into()])? | |
| 1959 | .run() | |
| 1960 | .await?; | |
| 1961 | for job in self.job_rows(&run.id).await?.iter().filter(|job| job.status != "completed") { | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 1962 | if force { |
| 1963 | self.hard_stop(job, reason).await?; | |
| 1964 | } else { | |
| 1965 | self.stop_job(job, reason).await?; | |
| 1966 | } | |
| The balance shows what was spent this month and in total | 1967 | } |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1968 | if run.status == "pending" || run.status == "action_required" { |
| The balance shows what was spent this month and in total | 1969 | self.db.prepare("UPDATE runs SET status = 'queued' WHERE id = ?").bind(&[run.id.as_str().into()])?.run().await?; |
| 1970 | } | |
| 1971 | self.advance(&run.id).await | |
| 1972 | } | |
| 1973 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1974 | /// Cancels every run of the repository that has not finished, for |
| 1975 | /// `repo.deleted` and `repo.archived`. | |
| 1976 | pub async fn stop_runs(&self, repo_id: &str) -> Result<()> { | |
| 1977 | let runs = self | |
| 1978 | .db | |
| 1979 | .prepare("SELECT * FROM runs WHERE repo_id = ? AND status != 'completed'") | |
| 1980 | .bind(&[repo_id.into()])? | |
| 1981 | .all() | |
| 1982 | .await? | |
| 1983 | .results::<RunRow>()?; | |
| 1984 | for run in runs { | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 1985 | self.cancel_run(&run, "The repository was archived or deleted.", true).await?; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1986 | } |
| 1987 | Ok(()) | |
| 1988 | } | |
| 1989 | ||
| The balance shows what was spent this month and in total | 1990 | pub async fn cancel(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1991 | if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? { |
| The balance shows what was spent this month and in total | 1992 | return Ok(Outcome::Fail(refused)); |
| 1993 | } | |
| 1994 | let run = check!(self.run_in(&a.repo, &a.id).await?); | |
| 1995 | if run.status == "completed" { | |
| 1996 | return Ok(fail(FailureCode::Conflict, "The run has already finished.")); | |
| 1997 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 1998 | // Cancelling a run that is already cancelling stops its jobs |
| 1999 | // outright, without waiting for their cleanup steps. | |
| 2000 | let force = a.force || run.conclusion.as_deref() == Some("cancelled"); | |
| 2001 | let reason = if force { | |
| 2002 | format!("{} stopped the run without waiting for its cleanup steps.", a.actor.username) | |
| 2003 | } else { | |
| 2004 | format!("{} cancelled the run.", a.actor.username) | |
| 2005 | }; | |
| 2006 | self.cancel_run(&run, &reason, force).await?; | |
| The balance shows what was spent this month and in total | 2007 | self.run_summary(&run.id).await |
| 2008 | } | |
| 2009 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2010 | /// Runs again, as a new attempt: every job, with `failed_only` those |
| 2011 | /// that did not succeed, or with `job` that one; each with the jobs that | |
| 2012 | /// need them. The attempt that ends is kept, its jobs and their logs and | |
| 2013 | /// summaries with it (`job_attempts`, `run_attempts`). | |
| The balance shows what was spent this month and in total | 2014 | pub async fn rerun(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 2015 | if let Outcome::Fail(refused) = self.may(&a.actor, &a.repo, Capability::Run).await? { |
| The balance shows what was spent this month and in total | 2016 | return Ok(Outcome::Fail(refused)); |
| 2017 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2018 | // A job alone (`POST …/jobs/{job}/rerun`) names its run. |
| 2019 | let run_id = match (&a.job, a.id.is_empty()) { | |
| 2020 | (Some(job), true) => { | |
| 2021 | #[derive(Deserialize)] | |
| 2022 | struct Of { | |
| 2023 | run_id: String, | |
| 2024 | } | |
| 2025 | let of = self.db.prepare("SELECT run_id FROM jobs WHERE id = ?").bind(&[job.as_str().into()])?.first::<Of>(None).await?; | |
| 2026 | match of { | |
| 2027 | Some(of) => of.run_id, | |
| 2028 | None => return Ok(fail(FailureCode::NotFound, "No such job.")), | |
| 2029 | } | |
| 2030 | } | |
| 2031 | _ => a.id.clone(), | |
| 2032 | }; | |
| 2033 | let run = check!(self.run_in(&a.repo, &run_id).await?); | |
| The balance shows what was spent this month and in total | 2034 | if run.status != "completed" { |
| 2035 | return Ok(fail(FailureCode::Conflict, "The run is still going: cancel it first.")); | |
| 2036 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 2037 | // Nothing starts again on an archived repository. |
| 2038 | match self.visible_repo(&a.repo, &Some(a.actor.clone())).await? { | |
| 2039 | Some(repo) if repo.archived() => { | |
| 2040 | return Ok(fail(FailureCode::Forbidden, g1t_contracts::repos::archived_message(&repo.namespace, &repo.name))); | |
| 2041 | } | |
| 2042 | Some(_) => {} | |
| 2043 | None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")), | |
| 2044 | } | |
| The balance shows what was spent this month and in total | 2045 | if run.error.is_some() { |
| 2046 | return Ok(fail(FailureCode::Conflict, "This run never started: fix the workflow file and push again.")); | |
| 2047 | } | |
| 2048 | let jobs = self.job_rows(&run.id).await?; | |
| 2049 | let workflow = workflow::parse(&run.source).ok(); | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2050 | let target = match &a.job { |
| 2051 | Some(id) => match jobs.iter().find(|job| &job.id == id) { | |
| 2052 | Some(job) => Some(top_key(&job.key).to_owned()), | |
| 2053 | None => return Ok(fail(FailureCode::NotFound, "That job is not in the run's latest attempt.")), | |
| 2054 | }, | |
| 2055 | None => None, | |
| 2056 | }; | |
| 2057 | let which = match (&target, a.failed_only) { | |
| 2058 | (Some(key), _) => Rerun::Job(key), | |
| 2059 | (None, true) => Rerun::Failed, | |
| 2060 | (None, false) => Rerun::All, | |
| 2061 | }; | |
| 2062 | let order = workflow.as_ref().map(|w| w.job_order()).unwrap_or_default(); | |
| 2063 | let again = rerun_keys( | |
| 2064 | &order, | |
| 2065 | |key| jobs.iter().find(|j| j.key == key).map(JobRow::needs).unwrap_or_default(), | |
| 2066 | |key| jobs.iter().filter(|j| j.key == key).all(|j| j.conclusion.as_deref() == Some("success")), | |
| 2067 | which, | |
| 2068 | ); | |
| The balance shows what was spent this month and in total | 2069 | if again.is_empty() { |
| 2070 | return Ok(fail(FailureCode::Conflict, "Every job succeeded: there is nothing to run again.")); | |
| 2071 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2072 | // The jobs that run again, a called workflow's with the job calling it. |
| 2073 | let rerun_ids: Vec<&str> = jobs.iter().filter(|job| again.iter().any(|key| key == top_key(&job.key))).map(|job| job.id.as_str()).collect(); | |
| 2074 | let ids = serde_json::to_string(&rerun_ids)?; | |
| 2075 | let ended = run.attempt.to_string(); | |
| 2076 | let ended = ended.as_str(); | |
| 2077 | let mut statements = vec![ | |
| 2078 | // The attempt that ends, as it ended. | |
| 2079 | self.db | |
| 2080 | .prepare( | |
| 2081 | "INSERT OR REPLACE INTO run_attempts (run_id, attempt, repo_id, conclusion, actor, debug, started_at, finished_at) | |
| 2082 | SELECT id, attempt, repo_id, conclusion, COALESCE(triggering_actor, actor), debug, started_at, finished_at FROM runs WHERE id = ?", | |
| 2083 | ) | |
| 2084 | .bind(&[run.id.as_str().into()])?, | |
| 2085 | // Earlier attempts that showed a job's logs from where it ran | |
| 2086 | // then now find them where they move to. | |
| 2087 | self.db | |
| 2088 | .prepare("UPDATE job_attempts SET log_id = log_id || '.' || ?1 WHERE run_id = ?2 AND log_id IN (SELECT value FROM json_each(?3))") | |
| 2089 | .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?, | |
| 2090 | // Each of its jobs: one that runs again keeps its logs under | |
| 2091 | // `{id}.{attempt}`; one left alone is still the live job's. | |
| 2092 | self.db | |
| 2093 | .prepare( | |
| 2094 | "INSERT OR REPLACE INTO job_attempts (id, run_id, repo_id, attempt, job_id, log_id, key, ordinal, name, needs, status, conclusion, | |
| 2095 | steps, annotations, reason, environment, labels, runner_name, started_at, finished_at) | |
| 2096 | SELECT id || '.' || ?1, run_id, repo_id, ?1, id, | |
| 2097 | CASE WHEN id IN (SELECT value FROM json_each(?3)) THEN id || '.' || ?1 ELSE id END, | |
| 2098 | key, ordinal, name, needs, status, conclusion, steps, annotations, reason, environment, labels, runner_name, started_at, finished_at | |
| 2099 | FROM jobs WHERE run_id = ?2 ORDER BY rowid", | |
| 2100 | ) | |
| 2101 | .bind(&[ended.into(), run.id.as_str().into(), ids.as_str().into()])?, | |
| 2102 | self.db | |
| 2103 | .prepare("UPDATE logs SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))") | |
| 2104 | .bind(&[ended.into(), ids.as_str().into()])?, | |
| 2105 | self.db | |
| 2106 | .prepare("UPDATE job_summaries SET job_id = job_id || '.' || ?1 WHERE job_id IN (SELECT value FROM json_each(?2))") | |
| 2107 | .bind(&[ended.into(), ids.as_str().into()])?, | |
| 2108 | ]; | |
| The balance shows what was spent this month and in total | 2109 | for key in &again { |
| 2110 | statements.push(self.db.prepare("DELETE FROM jobs WHERE run_id = ? AND key = ? AND ordinal > 0").bind(&[run.id.as_str().into(), key.as_str().into()])?); | |
| 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. | 2111 | // The jobs of a workflow it called (each matrix combination's |
| 2112 | // under `key[i]/`) are made again when it calls it again. | |
| The balance shows what was spent this month and in total | 2113 | statements.push( |
| 2114 | self.db | |
| 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. | 2115 | .prepare("DELETE FROM jobs WHERE run_id = ? AND (key LIKE ? OR key LIKE ?)") |
| 2116 | .bind(&[run.id.as_str().into(), format!("{key}/%").into(), format!("{key}[%").into()])?, | |
| The balance shows what was spent this month and in total | 2117 | ); |
| 2118 | statements.push( | |
| 2119 | self.db | |
| 2120 | .prepare( | |
| 2121 | "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2122 | matrix = NULL, call = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL, |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2123 | labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL, |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2124 | concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?", |
| The balance shows what was spent this month and in total | 2125 | ) |
| 2126 | .bind(&[run.id.as_str().into(), key.as_str().into()])?, | |
| 2127 | ); | |
| 2128 | } | |
| 2129 | statements.push( | |
| 2130 | self.db | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2131 | .prepare( |
| 2132 | "UPDATE runs SET status = 'queued', conclusion = NULL, attempt = attempt + 1, started_at = NULL, finished_at = NULL, | |
| 2133 | triggering_actor = ?, debug = ? WHERE id = ?", | |
| 2134 | ) | |
| 2135 | .bind(&[a.actor.username.as_str().into(), u32::from(a.debug).into(), run.id.as_str().into()])?, | |
| The balance shows what was spent this month and in total | 2136 | ); |
| 2137 | self.db.batch(statements).await?; | |
| 2138 | if let Some(run) = self.run_row(&run.id).await? { | |
| 2139 | self.report_pending(&run).await?; | |
| 2140 | } | |
| 2141 | self.advance(&run.id).await?; | |
| 2142 | self.run_summary(&run.id).await | |
| 2143 | } | |
| 2144 | ||
| 2145 | pub async fn run_in(&self, repo: &RepoPath, id: &str) -> Result<Outcome<RunRow>> { | |
| 2146 | let row = self | |
| 2147 | .db | |
| 2148 | .prepare("SELECT * FROM runs WHERE id = ? AND lower(repo) = lower(?)") | |
| 2149 | .bind(&[id.into(), format!("{}/{}", repo.namespace, repo.name).into()])? | |
| 2150 | .first::<RunRow>(None) | |
| 2151 | .await?; | |
| 2152 | Ok(row.map_or_else(|| fail(FailureCode::NotFound, "No such run."), Outcome::Ok)) | |
| 2153 | } | |
| 2154 | ||
| 2155 | // --- The sandbox's side ----------------------------------------------------- | |
| 2156 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2157 | pub(crate) async fn job_for_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> { |
| The balance shows what was spent this month and in total | 2158 | let job = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.job.as_str().into()])?.first::<JobRow>(None).await?; |
| 2159 | Ok(match job { | |
| 2160 | Some(job) if job.status == "in_progress" && job.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(&a.token))) => { | |
| 2161 | Outcome::Ok(job) | |
| 2162 | } | |
| 2163 | _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."), | |
| 2164 | }) | |
| 2165 | } | |
| 2166 | ||
| 2167 | /// `job_auth`: which run and repository a running job's token is for, | |
| 2168 | /// so the API can keep its artifacts and cache. | |
| 2169 | pub async fn job_auth(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 2170 | let job = check!(self.job_for_token(&a).await?); | |
| 2171 | Ok(Outcome::Ok(json!({ "run": job.run_id, "repoId": job.repo_id }))) | |
| 2172 | } | |
| 2173 | ||
| 2174 | /// `job_spec`: everything the sandbox needs to run the job. | |
| 2175 | pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 2176 | let job = check!(self.job_for_token(&a).await?); | |
| 2177 | let Some(run) = self.run_row(&job.run_id).await? else { | |
| 2178 | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 2179 | }; | |
| 2180 | let Ok(caller) = workflow::parse(&run.source) else { | |
| 2181 | return Ok(fail(FailureCode::Invalid, "The workflow no longer reads.")); | |
| 2182 | }; | |
| 2183 | // A called workflow's job runs as that workflow defines it. | |
| 2184 | let callee = job.callee(); | |
| 2185 | let (workflow, spec, call_inputs) = match callee { | |
| 2186 | Some((called, spec, call)) => (called, spec, Some(call["inputs"].clone())), | |
| 2187 | None => match caller.jobs.iter().find(|j| j.id == job.key) { | |
| 2188 | Some(spec) => (caller.clone(), spec.clone(), None), | |
| 2189 | None => return Ok(fail(FailureCode::NotFound, "The job is not in the workflow.")), | |
| 2190 | }, | |
| 2191 | }; | |
| 2192 | let spec = &spec; | |
| 2193 | let repo = repo_path(&run.repo); | |
| 2194 | let trusted = run.trusted != 0; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2195 | // The job's `environment:`, by name, as read when its needs were done |
| 2196 | // (an expression included), once the environment's protection rules | |
| 2197 | // let it start: entries with a value for it give that value instead | |
| 2198 | // of their default, as GitHub's environment secrets do. | |
| 2199 | let environment: Option<String> = job.environment.clone(); | |
| 2200 | // What its token may do: its `permissions:` (a called workflow's | |
| 2201 | // jobs no more than the job that calls it), else the repository's | |
| 2202 | // default; read-only for a pull request from outside. | |
| 2203 | // The repository's default, its workspace's taken in, and whether | |
| 2204 | // its jobs may open and approve pull requests. | |
| 2205 | let (default, pull_requests) = self.token_policy(&run.repo_id).await?; | |
| 2206 | let mut permissions = spec.permissions(&workflow, default); | |
| 2207 | if let Some(parent) = &job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::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. | 2208 | let top = top_key(parent); |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2209 | if let Some(caller_job) = caller.jobs.iter().find(|j| j.id == top) { |
| 2210 | permissions = permissions.capped_by(&caller_job.permissions(&caller, default)); | |
| The balance shows what was spent this month and in total | 2211 | } |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2212 | } |
| 2213 | if !trusted { | |
| 2214 | permissions = permissions.read_only(); | |
| 2215 | } | |
| 2216 | // G1T_TOKEN, and GITHUB_TOKEN as its alias: a token of the | |
| 2217 | // workspace's that reaches this repository only, with the scopes | |
| 2218 | // its permissions give, until the job ends. | |
| 2219 | let token = match self.workspace_actor(&repo.namespace).await? { | |
| 2220 | Some(workspace) => { | |
| 2221 | let created: CreatedAccessToken = g1t_kit::call( | |
| 2222 | &self.identity, | |
| 2223 | "create_job_token", | |
| 2224 | &CreateJobTokenArgs { | |
| 2225 | workspace, | |
| 2226 | repo: repo.clone(), | |
| 2227 | run_id: run.id.clone(), | |
| 2228 | job_id: job.id.clone(), | |
| 2229 | name: format!("G1T_TOKEN for {} run {}", run.repo, run.number), | |
| 2230 | ttl_seconds: u64::from(job.timeout_minutes) * 60 + 600, | |
| 2231 | scopes: permissions.scopes().into_iter().map(str::to_owned).collect(), | |
| 2232 | pull_requests: pull_requests && trusted, | |
| 2233 | }, | |
| 2234 | ) | |
| 2235 | .await?; | |
| 2236 | created.token | |
| 2237 | } | |
| 2238 | None => String::new(), | |
| The balance shows what was spent this month and in total | 2239 | }; |
| 2240 | // A run that is not trusted (a pull request from outside the | |
| 2241 | // workspace) gets no secrets and an empty token. | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 2242 | let passed = job.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned()); |
| 2243 | let mut secrets = match (trusted, passed) { | |
| 2244 | (false, _) => Map::new(), | |
| 2245 | (true, None) => self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?, | |
| 2246 | // A called workflow's job: what its callers passed it (reach.rs), | |
| 2247 | // and its own environment's secrets over them. | |
| 2248 | (true, Some(plan)) => { | |
| 2249 | let base = self.secrets_for(&run.repo_id, &run.repo, None, true).await?; | |
| 2250 | let vars = self.variables_for(&run.repo_id, &run.repo, None, true).await?; | |
| 2251 | let github = run.info().context(&job.key, "", run.action.as_deref()); | |
| 2252 | let mut passed = crate::reach::resolve_secrets(&plan, &base, &github, &vars); | |
| 2253 | if let Some(name) = environment.as_deref() { | |
| 2254 | let own = self.secrets_for(&run.repo_id, &run.repo, Some(name), true).await?; | |
| 2255 | for (key, value) in own { | |
| 2256 | if base.get(&key) != Some(&value) { | |
| 2257 | passed.insert(key, value); | |
| 2258 | } | |
| 2259 | } | |
| 2260 | } | |
| 2261 | passed | |
| 2262 | } | |
| The balance shows what was spent this month and in total | 2263 | }; |
| 2264 | secrets.insert("G1T_TOKEN".into(), Value::String(token.clone())); | |
| 2265 | secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone())); | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2266 | // Each secret as it is, a line at a time, base64 and JSON-escaped. |
| 2267 | let mut masks: Vec<String> = g1t_actions::mask::all_variants(secrets.values().filter_map(|v| v.as_str())); | |
| The balance shows what was spent this month and in total | 2268 | let vars = self.variables_for(&run.repo_id, &run.repo, environment.as_deref(), trusted).await?; |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2269 | // The toolkit's runtime token (runtime.rs), for as long as the job |
| 2270 | // may run; the API puts it and the toolkit's addresses in the | |
| 2271 | // job's variables. | |
| 2272 | let runtime_token = crate::runtime::runtime_token( | |
| 2273 | &job.id, | |
| 2274 | &job.run_id, | |
| 2275 | job.token_hash.as_deref().unwrap_or_default(), | |
| 2276 | now_ms() / 1000, | |
| 2277 | u64::from(job.timeout_minutes) * 60 + 600, | |
| 2278 | ); | |
| 2279 | masks.push(runtime_token.clone()); | |
| 2280 | let retention_days = self.retention_setting(&run.repo_id).await?; | |
| The balance shows what was spent this month and in total | 2281 | |
| 2282 | let jobs = self.job_rows(&run.id).await?; | |
| 2283 | let mut needs = Map::new(); | |
| 2284 | // In a called workflow, its jobs' keys sit under the job that called it. | |
| 2285 | let parent = job.call().filter(|c| c["role"] == "callee").and_then(|c| c["parent"].as_str().map(str::to_owned)); | |
| 2286 | for need in &spec.needs { | |
| 2287 | let key = match &parent { | |
| 2288 | Some(parent) => format!("{parent}/{need}"), | |
| 2289 | None => need.clone(), | |
| 2290 | }; | |
| 2291 | let rows: Vec<&JobRow> = jobs.iter().filter(|row| row.key == key).collect(); | |
| 2292 | let mut outputs = Map::new(); | |
| 2293 | for row in &rows { | |
| 2294 | if let Ok(Value::Object(more)) = serde_json::from_str::<Value>(&row.outputs) { | |
| 2295 | outputs.extend(more); | |
| 2296 | } | |
| 2297 | } | |
| 2298 | needs.insert(need.clone(), json!({ "result": key_result(&rows), "outputs": outputs })); | |
| 2299 | } | |
| 2300 | let siblings = jobs.iter().filter(|row| row.key == job.key).count(); | |
| 2301 | let matrix: Value = job.matrix.as_deref().and_then(|m| serde_json::from_str(m).ok()).unwrap_or(json!({})); | |
| 2302 | let info = run.info(); | |
| 2303 | let mut github = info.context(&job.key, &token, run.action.as_deref()); | |
| 2304 | github["token"] = json!(token); | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2305 | github["retention_days"] = json!(retention_days); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2306 | // On a self-hosted runner, `runner` and `RUNNER_*` describe that |
| 2307 | // machine rather than g1t's sandbox. | |
| 2308 | let mut variables = info.variables(&job.key); | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2309 | variables.insert("GITHUB_RETENTION_DAYS".into(), json!(retention_days.to_string())); |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2310 | let mut runner = match &job.runner_id { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2311 | Some(id) => self.runner_context_for(id, &mut variables).await?, |
| 2312 | None => runner_context(), | |
| 2313 | }; | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2314 | // A re-run with debug logging: what GitHub sets for one. |
| 2315 | if run.debug != 0 { | |
| 2316 | debug_logging(&mut variables, &mut runner); | |
| 2317 | } | |
| The balance shows what was spent this month and in total | 2318 | |
| 2319 | // Where to check out: a pull request's fork, or the repository. | |
| 2320 | let clone_url = match run.pull { | |
| 2321 | Some(number) if run.event.starts_with("pull_request") && run.event != "pull_request_target" => { | |
| 2322 | let located: Outcome<g1t_contracts::work::PullDetail> = g1t_kit::call( | |
| 2323 | &self.work, | |
| 2324 | "get_pull", | |
| 2325 | &g1t_contracts::work::ViewArgs { | |
| 2326 | repo: repo.clone(), | |
| 2327 | number, | |
| 2328 | viewer: self.workspace_actor(&repo.namespace).await?, | |
| 2329 | after_seq: 0, | |
| 2330 | }, | |
| 2331 | ) | |
| 2332 | .await?; | |
| 2333 | match located { | |
| 2334 | Outcome::Ok(detail) => match detail.pull.fork { | |
| 2335 | Some(fork) => format!("{SITE}/{}/{}.git", fork.namespace, fork.name), | |
| 2336 | None => format!("{SITE}/{}.git", run.repo), | |
| 2337 | }, | |
| 2338 | Outcome::Fail(_) => format!("{SITE}/{}.git", run.repo), | |
| 2339 | } | |
| 2340 | } | |
| 2341 | _ => format!("{SITE}/{}.git", run.repo), | |
| 2342 | }; | |
| 2343 | ||
| 2344 | Ok(Outcome::Ok(json!({ | |
| 2345 | "job": job.id, | |
| 2346 | "run": run.id, | |
| 2347 | "key": job.key, | |
| 2348 | "name": job.name, | |
| 2349 | "spec": spec.raw, | |
| 2350 | "workflow": { | |
| 2351 | "env": workflow.env, | |
| 2352 | "defaults": workflow.raw.get("defaults").cloned().unwrap_or(Value::Null), | |
| 2353 | }, | |
| 2354 | "github": github, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2355 | "variables": variables, |
| The balance shows what was spent this month and in total | 2356 | "event": info.event, |
| 2357 | "contexts": { | |
| 2358 | "vars": vars, | |
| 2359 | "secrets": secrets, | |
| 2360 | "inputs": call_inputs.unwrap_or_else(|| Value::Object(run.inputs())), | |
| 2361 | "matrix": matrix, | |
| 2362 | "needs": needs, | |
| 2363 | "strategy": { | |
| 2364 | "fail-fast": spec.fail_fast, | |
| 2365 | "job-index": job.ordinal, | |
| 2366 | "job-total": siblings, | |
| 2367 | "max-parallel": spec.max_parallel.unwrap_or(siblings as u32), | |
| 2368 | }, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2369 | "runner": runner, |
| The balance shows what was spent this month and in total | 2370 | }, |
| 2371 | "checkout": { | |
| 2372 | "repository": run.repo, | |
| 2373 | "url": clone_url, | |
| 2374 | "sha": run.sha, | |
| 2375 | "ref": run.git_ref, | |
| 2376 | "token": token, | |
| 2377 | }, | |
| 2378 | "timeoutMinutes": job.timeout_minutes, | |
| 2379 | "masks": masks, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2380 | // As the job's log lists them at its start. |
| 2381 | "permissions": permissions.listed().into_iter().map(|(name, access)| (name.to_owned(), json!(access.as_str()))).collect::<Map<String, Value>>(), | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2382 | // Whether the job may ask for an OIDC token decides whether it |
| 2383 | // is told where to. | |
| 2384 | "runtime": { | |
| 2385 | "token": runtime_token, | |
| 2386 | "idToken": self.oidc_allowed(&run, &job), | |
| 2387 | }, | |
| The balance shows what was spent this month and in total | 2388 | }))) |
| 2389 | } | |
| 2390 | ||
| 2391 | /// `job_report`: the sandbox telling how the job is going. | |
| 2392 | pub async fn job_report(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 2393 | let job = check!(self.job_for_token(&a).await?); | |
| 2394 | let report = &a.report; | |
| 2395 | let at = now(); | |
| 2396 | match report["kind"].as_str().unwrap_or_default() { | |
| 2397 | "steps" => { | |
| 2398 | // The list can grow as the job goes (post steps), so steps | |
| 2399 | // already reported keep where they stand. | |
| 2400 | let known: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default(); | |
| 2401 | let steps: Vec<Value> = report["steps"] | |
| 2402 | .as_array() | |
| 2403 | .map(|names| { | |
| 2404 | names | |
| 2405 | .iter() | |
| 2406 | .enumerate() | |
| 2407 | .map(|(i, name)| match known.get(i) { | |
| 2408 | Some(step) if step["status"] != "queued" => step.clone(), | |
| 2409 | _ => json!({ "number": i + 1, "name": expr::to_text(name), "status": "queued", "conclusion": null, "startedAt": null, "finishedAt": null }), | |
| 2410 | }) | |
| 2411 | .collect() | |
| 2412 | }) | |
| 2413 | .unwrap_or_default(); | |
| 2414 | self.db | |
| 2415 | .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?") | |
| 2416 | .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])? | |
| 2417 | .run() | |
| 2418 | .await?; | |
| 2419 | } | |
| 2420 | "step" => { | |
| 2421 | let number = report["number"].as_u64().unwrap_or(0) as usize; | |
| 2422 | let mut steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default(); | |
| 2423 | if let Some(step) = number.checked_sub(1).and_then(|i| steps.get_mut(i)) { | |
| 2424 | let status = report["status"].as_str().unwrap_or("in_progress"); | |
| 2425 | step["status"] = json!(status); | |
| 2426 | if status == "in_progress" { | |
| 2427 | step["startedAt"] = json!(at); | |
| 2428 | } | |
| 2429 | if status == "completed" { | |
| 2430 | step["finishedAt"] = json!(at); | |
| 2431 | step["conclusion"] = report["conclusion"].clone(); | |
| 2432 | } | |
| 2433 | if let Some(name) = report["name"].as_str() { | |
| 2434 | step["name"] = json!(name); | |
| 2435 | } | |
| 2436 | } | |
| 2437 | self.db | |
| 2438 | .prepare("UPDATE jobs SET steps = ?, seen_at = ? WHERE id = ?") | |
| 2439 | .bind(&[serde_json::to_string(&steps)?.into(), at.as_str().into(), job.id.as_str().into()])? | |
| 2440 | .run() | |
| 2441 | .await?; | |
| 2442 | } | |
| 2443 | "log" => { | |
| 2444 | let mut text = report["text"].as_str().unwrap_or_default().to_owned(); | |
| 2445 | if text.len() > MAX_CHUNK_BYTES { | |
| 2446 | let mut cut = MAX_CHUNK_BYTES; | |
| 2447 | while !text.is_char_boundary(cut) { | |
| 2448 | cut -= 1; | |
| 2449 | } | |
| 2450 | text.truncate(cut); | |
| 2451 | } | |
| 2452 | #[derive(Deserialize)] | |
| 2453 | struct Size { | |
| 2454 | n: Option<f64>, | |
| 2455 | seq: Option<f64>, | |
| 2456 | } | |
| 2457 | let size = self | |
| 2458 | .db | |
| 2459 | .prepare("SELECT SUM(LENGTH(text)) AS n, MAX(seq) AS seq FROM logs WHERE job_id = ?") | |
| 2460 | .bind(&[job.id.as_str().into()])? | |
| 2461 | .first::<Size>(None) | |
| 2462 | .await?; | |
| 2463 | let (used, seq) = size.map_or((0, 0.0), |s| (s.n.unwrap_or(0.0) as usize, s.seq.unwrap_or(0.0))); | |
| 2464 | if used < MAX_LOG_BYTES { | |
| 2465 | if used + text.len() >= MAX_LOG_BYTES { | |
| 2466 | text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n"); | |
| 2467 | } | |
| 2468 | self.db | |
| 2469 | .prepare("INSERT INTO logs (job_id, seq, step, text) VALUES (?, ?, ?, ?)") | |
| 2470 | .bind(&[job.id.as_str().into(), // Numbers go to D1 as f64: a u64 would be a BigInt, which it refuses. | |
| 2471 | (seq + 1.0).into(), (report["step"].as_u64().unwrap_or(0) as u32).into(), text.into()])? | |
| 2472 | .run() | |
| 2473 | .await?; | |
| 2474 | } | |
| 2475 | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?; | |
| 2476 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2477 | "summary" => { |
| 2478 | // `$GITHUB_STEP_SUMMARY`, masked by the runner: added to the | |
| 2479 | // step's summary, up to 1 MiB a step and 20 steps a job. | |
| 2480 | let step = report["step"].as_u64().unwrap_or(0) as u32; | |
| 2481 | let markdown = report["markdown"].as_str().unwrap_or_default(); | |
| 2482 | #[derive(Deserialize)] | |
| 2483 | struct Held { | |
| 2484 | steps: u32, | |
| 2485 | mine: Option<f64>, | |
| 2486 | } | |
| 2487 | let held = self | |
| 2488 | .db | |
| 2489 | .prepare("SELECT COUNT(*) AS steps, MAX(CASE WHEN step = ? THEN LENGTH(markdown) END) AS mine FROM job_summaries WHERE job_id = ?") | |
| 2490 | .bind(&[step.into(), job.id.as_str().into()])? | |
| 2491 | .first::<Held>(None) | |
| 2492 | .await?; | |
| 2493 | let (steps, mine) = held.map_or((0, None), |held| (held.steps, held.mine.map(|n| n as usize))); | |
| 2494 | if summary_fits(steps, mine, markdown.len()) { | |
| 2495 | self.db | |
| 2496 | .prepare( | |
| 2497 | "INSERT INTO job_summaries (job_id, step, markdown) VALUES (?, ?, ?) | |
| 2498 | ON CONFLICT (job_id, step) DO UPDATE SET markdown = job_summaries.markdown || excluded.markdown", | |
| 2499 | ) | |
| 2500 | .bind(&[job.id.as_str().into(), step.into(), markdown.into()])? | |
| 2501 | .run() | |
| 2502 | .await?; | |
| 2503 | } | |
| 2504 | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?; | |
| 2505 | } | |
| The balance shows what was spent this month and in total | 2506 | "annotation" => { |
| 2507 | let mut annotations: Vec<Value> = serde_json::from_str(&job.annotations).unwrap_or_default(); | |
| 2508 | if annotations.len() < MAX_ANNOTATIONS { | |
| 2509 | annotations.push(json!({ | |
| 2510 | "level": report["level"].as_str().unwrap_or("notice"), | |
| 2511 | "message": report["message"].as_str().unwrap_or_default().chars().take(4000).collect::<String>(), | |
| 2512 | "title": report["title"], | |
| 2513 | "file": report["file"], | |
| 2514 | "line": report["line"], | |
| 2515 | })); | |
| 2516 | self.db | |
| 2517 | .prepare("UPDATE jobs SET annotations = ?, seen_at = ? WHERE id = ?") | |
| 2518 | .bind(&[serde_json::to_string(&annotations)?.into(), at.as_str().into(), job.id.as_str().into()])? | |
| 2519 | .run() | |
| 2520 | .await?; | |
| 2521 | } | |
| 2522 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2523 | // Nothing to tell; the answer says whether to stop. |
| 2524 | "ping" => { | |
| 2525 | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?; | |
| 2526 | } | |
| The balance shows what was spent this month and in total | 2527 | "done" => { |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2528 | // A job told to stop ends cancelled, however its cleanup went. |
| 2529 | let conclusion = if job.cancel_requested_at.is_some() { | |
| 2530 | "cancelled" | |
| 2531 | } else { | |
| 2532 | report["conclusion"] | |
| 2533 | .as_str() | |
| 2534 | .filter(|c| matches!(*c, "success" | "failure" | "cancelled")) | |
| 2535 | .unwrap_or("failure") | |
| 2536 | }; | |
| The balance shows what was spent this month and in total | 2537 | let outputs = report["outputs"].as_object().cloned(); |
| 2538 | Box::pin(self.finish_job(&job.id, conclusion, report["reason"].as_str(), outputs.as_ref())).await?; | |
| 2539 | } | |
| 2540 | other => return Ok(fail(FailureCode::Invalid, format!("There is no report called `{other}`."))), | |
| 2541 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2542 | // `cancelled`: the run was cancelled, and the runner should stop |
| 2543 | // the step it is on and run only its cleanup steps. | |
| 2544 | Ok(Outcome::Ok(json!({ "ok": true, "cancelled": job.cancel_requested_at.is_some() }))) | |
| The balance shows what was spent this month and in total | 2545 | } |
| 2546 | ||
| 2547 | // --- Every minute --------------------------------------------------------------- | |
| 2548 | ||
| 2549 | pub async fn on_minute(&self, now_ms: u64) -> Result<()> { | |
| 2550 | let minute = now_ms / 60_000 * 60_000; | |
| Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006) | 2551 | // Staff (or billing's usage watcher) paused scheduled runs across |
| 2552 | // g1t: this minute's schedules are skipped, not queued for later. | |
| 2553 | // Kept 30 seconds in the isolate (g1t_kit::pause). | |
| 2554 | if g1t_kit::pause::paused(&self.billing, g1t_contracts::billing::PauseLevel::Schedules).await { | |
| 2555 | worker::console_log!("actions: schedules are paused across g1t; skipped this minute's"); | |
| 2556 | } else if let Err(error) = self.run_schedules(minute).await { | |
| The balance shows what was spent this month and in total | 2557 | worker::console_error!("actions: schedules failed: {error}"); |
| 2558 | } | |
| 2559 | // Jobs whose sandbox went quiet or ran past their time. | |
| 2560 | let running = self.db.prepare("SELECT * FROM jobs WHERE status = 'in_progress'").all().await?.results::<JobRow>()?; | |
| 2561 | for job in running { | |
| 2562 | // Times in g1t's format compare as text. | |
| 2563 | let before = |ms: u64| rfc3339(now_ms.saturating_sub(ms)); | |
| 2564 | let silent = job.seen_at.as_deref().is_some_and(|seen| seen < before(SILENT_MS).as_str()); | |
| 2565 | let limit = (u64::from(job.timeout_minutes) * 60 + 120) * 1000; | |
| 2566 | let over = job.started_at.as_deref().is_some_and(|started| started < before(limit).as_str()); | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2567 | let cancel_overdue = job.cancel_requested_at.as_deref().is_some_and(|at| at < before(CANCEL_GRACE_MS).as_str()); |
| 2568 | if cancel_overdue { | |
| 2569 | // Cancelled, and still going after its grace period. | |
| 2570 | self.hard_stop(&job, "It was cancelled, and did not finish its cleanup steps within 5 minutes.").await?; | |
| 2571 | self.advance(&job.run_id).await?; | |
| 2572 | } else if over { | |
| The balance shows what was spent this month and in total | 2573 | let reason = format!("It ran longer than its time limit of {} minutes.", job.timeout_minutes); |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2574 | // A self-hosted runner is told to stop on its next poll. |
| 2575 | if job.runner_id.is_none() { | |
| 2576 | let _: Result<Value> = g1t_kit::call(&self.runner, "stop_actions_job", &json!({ "job": job.id })).await; | |
| 2577 | } | |
| The balance shows what was spent this month and in total | 2578 | self.finish_job(&job.id, "failure", Some(&reason), None).await?; |
| 2579 | } else if silent { | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2580 | let reason = match &job.runner_name { |
| 2581 | Some(name) => format!("The self-hosted runner {name} stopped answering."), | |
| 2582 | None => "The runner stopped answering.".to_owned(), | |
| 2583 | }; | |
| 2584 | self.finish_job(&job.id, "failure", Some(&reason), None).await?; | |
| The balance shows what was spent this month and in total | 2585 | } |
| 2586 | } | |
| 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. | 2587 | // Calls of reusable workflows waiting in a concurrency group, in |
| 2588 | // case the job they waited for was cancelled in another run. | |
| 2589 | #[derive(Deserialize)] | |
| 2590 | struct RunId { | |
| 2591 | run_id: String, | |
| 2592 | } | |
| 2593 | let held = self | |
| 2594 | .db | |
| 2595 | .prepare("SELECT DISTINCT run_id FROM jobs WHERE status = 'waiting' AND concurrency_group IS NOT NULL LIMIT 20") | |
| 2596 | .all() | |
| 2597 | .await? | |
| 2598 | .results::<RunId>()?; | |
| 2599 | for run in held { | |
| 2600 | self.advance(&run.run_id).await?; | |
| 2601 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2602 | if let Err(error) = self.sweep_runners(now_ms).await { |
| 2603 | worker::console_error!("actions: the runners' sweep failed: {error}"); | |
| 2604 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2605 | // Jobs held at an environment whose wait timer has run out. |
| 2606 | if let Err(error) = self.release_gates().await { | |
| 2607 | worker::console_error!("actions: environments' gates failed: {error}"); | |
| 2608 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2609 | // Once an hour: the cache's expired entries, and its storage. |
| 2610 | if (now_ms / 60_000) % 60 == 7 | |
| 2611 | && let Err(error) = self.sweep_cache(now_ms).await | |
| 2612 | { | |
| 2613 | worker::console_error!("actions: the cache's sweep failed: {error}"); | |
| 2614 | } | |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 2615 | // And the archives of GitHub's actions no job uses any more. |
| 2616 | if (now_ms / 60_000) % 60 == 47 | |
| 2617 | && let Err(error) = self.sweep_action_archives(now_ms).await | |
| 2618 | { | |
| 2619 | worker::console_error!("actions: the action archives' sweep failed: {error}"); | |
| 2620 | } | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 2621 | // And artifacts past their time, and the toolkit's abandoned parts. |
| 2622 | if (now_ms / 60_000) % 60 == 37 { | |
| 2623 | if let Err(error) = self.sweep_artifacts(now_ms).await { | |
| 2624 | worker::console_error!("actions: the artifacts' sweep failed: {error}"); | |
| 2625 | } | |
| 2626 | if let Err(error) = self.sweep_blob_parts(now_ms).await { | |
| 2627 | worker::console_error!("actions: the blob parts' sweep failed: {error}"); | |
| 2628 | } | |
| 2629 | } | |
| The balance shows what was spent this month and in total | 2630 | self.start_queued().await |
| 2631 | } | |
| 2632 | } | |
| 2633 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 2634 | |
| 2635 | #[cfg(test)] | |
| 2636 | mod stopping { | |
| 2637 | use super::stops_runs; | |
| 2638 | use g1t_contracts::events::Event; | |
| 2639 | use serde_json::{Value, json}; | |
| 2640 | ||
| 2641 | fn event(kind: &str, data: Value) -> Event { | |
| 2642 | Event { | |
| 2643 | id: "evt_1".into(), | |
| 2644 | kind: kind.into(), | |
| 2645 | source: "repos".into(), | |
| 2646 | time: "2026-10-05T00:00:00Z".into(), | |
| 2647 | repo_id: Some("rep_1".into()), | |
| 2648 | actor: None, | |
| 2649 | data, | |
| 2650 | } | |
| 2651 | } | |
| 2652 | ||
| 2653 | #[test] | |
| 2654 | fn deleting_or_archiving_stops_runs() { | |
| 2655 | assert_eq!(stops_runs(&event("repo.deleted", json!({ "repoId": "rep_1" }))).as_deref(), Some("rep_1")); | |
| 2656 | assert_eq!(stops_runs(&event("repo.archived", json!({ "archived": true }))).as_deref(), Some("rep_1")); | |
| 2657 | assert_eq!(stops_runs(&event("repo.unarchived", json!({ "archived": false }))), None); | |
| 2658 | assert_eq!(stops_runs(&event("repo.restored", json!({}))), None); | |
| 2659 | assert_eq!(stops_runs(&event("git.push", json!({}))), None); | |
| 2660 | } | |
| 2661 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 2662 | |
| 2663 | #[cfg(test)] | |
| 2664 | mod status_of_needs { | |
| 2665 | use std::collections::HashMap; | |
| 2666 | ||
| 2667 | use super::ancestor_failed; | |
| 2668 | ||
| 2669 | /// check -> plan -> (migrate) -> core -> edge, as deploy.yml has them, | |
| 2670 | /// and a job that needs only the last. | |
| 2671 | fn graph() -> HashMap<&'static str, Vec<&'static str>> { | |
| 2672 | HashMap::from([ | |
| 2673 | ("check", vec![]), | |
| 2674 | ("plan", vec!["check"]), | |
| 2675 | ("migrate", vec!["plan"]), | |
| 2676 | ("core", vec!["plan", "migrate"]), | |
| 2677 | ("edge", vec!["plan", "migrate", "core"]), | |
| 2678 | ("notify", vec!["edge"]), | |
| 2679 | ]) | |
| 2680 | } | |
| 2681 | ||
| 2682 | #[test] | |
| 2683 | fn a_failure_is_seen_however_far_back() { | |
| 2684 | let needs = graph(); | |
| 2685 | let failed = |which: &'static str| move |key: &str| key == which; | |
| 2686 | // check failed; plan, and everything after, was skipped for it. | |
| 2687 | assert!(ancestor_failed(&needs, "notify", failed("check"))); | |
| 2688 | assert!(ancestor_failed(&needs, "core", failed("check"))); | |
| 2689 | assert!(ancestor_failed(&needs, "edge", failed("core"))); | |
| 2690 | // Nothing before a job failed: a skipped migrate is not a failure. | |
| 2691 | assert!(!ancestor_failed(&needs, "edge", |_| false)); | |
| 2692 | assert!(!ancestor_failed(&needs, "core", failed("edge"))); | |
| 2693 | assert!(!ancestor_failed(&needs, "check", failed("check"))); | |
| 2694 | } | |
| 2695 | ||
| 2696 | #[test] | |
| 2697 | fn cycles_and_unknown_keys_end() { | |
| 2698 | let needs = HashMap::from([("a", vec!["b"]), ("b", vec!["a"])]); | |
| 2699 | assert!(!ancestor_failed(&needs, "a", |_| false)); | |
| 2700 | assert!(!ancestor_failed(&needs, "missing", |_| true)); | |
| 2701 | } | |
| 2702 | } | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 2703 | |
| 2704 | #[cfg(test)] | |
| 2705 | mod deployments { | |
| 2706 | use serde_json::{Map, Value, json}; | |
| 2707 | ||
| 2708 | use super::{JobEnvironment, deployment_outcome, environment_of}; | |
| 2709 | ||
| 2710 | #[test] | |
| 2711 | fn a_jobs_environment_is_read_for_deployments() { | |
| 2712 | let contexts: Map<String, Value> = serde_json::from_value(json!({ | |
| 2713 | "github": { "ref_name": "main", "repository": "acme/web" }, | |
| 2714 | "inputs": { "target": "staging" }, | |
| 2715 | "matrix": {}, | |
| 2716 | })) | |
| 2717 | .unwrap(); | |
| 2718 | let read = |raw: Value| environment_of(&raw, &contexts); | |
| 2719 | assert_eq!(read(json!({})), None); | |
| 2720 | assert_eq!( | |
| 2721 | read(json!({ "environment": "production" })), | |
| 2722 | Some(JobEnvironment { name: "production".into(), url: None, deploys: true }) | |
| 2723 | ); | |
| 2724 | assert_eq!( | |
| 2725 | read(json!({ "environment": { "name": "production", "url": "https://g1t.sh" } })), | |
| 2726 | Some(JobEnvironment { name: "production".into(), url: Some("https://g1t.sh".into()), deploys: true }) | |
| 2727 | ); | |
| 2728 | // Expressions are filled in from the run. | |
| 2729 | assert_eq!( | |
| 2730 | read(json!({ "environment": { "name": "${{ inputs.target }}", "url": "https://${{ github.ref_name }}.example.com" } })), | |
| 2731 | Some(JobEnvironment { name: "staging".into(), url: Some("https://main.example.com".into()), deploys: true }) | |
| 2732 | ); | |
| 2733 | // Secrets only: no deployment. | |
| 2734 | assert!(!read(json!({ "environment": { "name": "production", "deployment": false } })).unwrap().deploys); | |
| 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. | 2735 | // The same, said in the job's g1t settings. |
| 2736 | assert!(!read(json!({ "environment": "production", "g1t": { "deployment": false } })).unwrap().deploys); | |
| 2737 | assert!(!read(json!({ "environment": { "name": "production" }, "g1t": { "deployment": false } })).unwrap().deploys); | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 2738 | // Only http(s) addresses. |
| 2739 | assert_eq!(read(json!({ "environment": { "name": "production", "url": "javascript:alert(1)" } })).unwrap().url, None); | |
| 2740 | } | |
| 2741 | ||
| 2742 | #[test] | |
| 2743 | fn a_runs_outcome_for_an_environment() { | |
| 2744 | let of = |list: &[&str]| deployment_outcome(&list.iter().map(|c| Some((*c).to_owned())).collect::<Vec<_>>()); | |
| 2745 | assert_eq!(of(&["success", "skipped"]), Some("success")); | |
| 2746 | assert_eq!(of(&["success", "failure"]), Some("failure")); | |
| 2747 | assert_eq!(of(&["success", "cancelled"]), Some("error")); | |
| 2748 | assert_eq!(of(&["skipped"]), None); | |
| 2749 | assert_eq!(deployment_outcome(&[None]), None); | |
| 2750 | } | |
| 2751 | } | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2752 | |
| 2753 | #[cfg(test)] | |
| 2754 | mod reruns { | |
| 2755 | use serde_json::{Map, Value, json}; | |
| 2756 | ||
| 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. | 2757 | use super::{MAX_SUMMARIES, MAX_SUMMARY_BYTES, Rerun, Scope, Status, call_allowed, call_prefix, debug_logging, rerun_keys, shown_name, summary_fits, top_key}; |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2758 | |
| 2759 | /// build ← test ← deploy, and lint on its own. | |
| 2760 | fn keys(which: Rerun, failed: &[&str]) -> Vec<String> { | |
| 2761 | let order = ["build", "lint", "test", "deploy"]; | |
| 2762 | let needs = |key: &str| -> Vec<String> { | |
| 2763 | match key { | |
| 2764 | "test" => vec!["build".into()], | |
| 2765 | "deploy" => vec!["test".into()], | |
| 2766 | _ => Vec::new(), | |
| 2767 | } | |
| 2768 | }; | |
| 2769 | rerun_keys(&order, needs, |key| !failed.contains(&key), which) | |
| 2770 | } | |
| 2771 | ||
| 2772 | #[test] | |
| 2773 | fn a_rerun_takes_the_jobs_it_names_and_those_that_need_them() { | |
| 2774 | assert_eq!(keys(Rerun::All, &[]), ["build", "lint", "test", "deploy"]); | |
| 2775 | assert_eq!(keys(Rerun::Failed, &["test"]), ["test", "deploy"]); | |
| 2776 | assert_eq!(keys(Rerun::Failed, &["lint"]), ["lint"]); | |
| 2777 | assert!(keys(Rerun::Failed, &[]).is_empty()); | |
| 2778 | // One job, whatever it came to, and what depends on it. | |
| 2779 | assert_eq!(keys(Rerun::Job("build"), &[]), ["build", "test", "deploy"]); | |
| 2780 | assert_eq!(keys(Rerun::Job("lint"), &["test"]), ["lint"]); | |
| 2781 | assert_eq!(keys(Rerun::Job("deploy"), &[]), ["deploy"]); | |
| 2782 | assert!(keys(Rerun::Job("missing"), &[]).is_empty()); | |
| 2783 | } | |
| 2784 | ||
| 2785 | #[test] | |
| 2786 | fn a_called_workflows_jobs_run_again_with_the_job_that_calls_it() { | |
| 2787 | assert_eq!(top_key("build/test"), "build"); | |
| 2788 | assert_eq!(top_key("build/inner/test"), "build"); | |
| 2789 | assert_eq!(top_key("lint"), "lint"); | |
| 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. | 2790 | // A matrix's calls each have their own jobs, under `key[i]/`. |
| 2791 | assert_eq!(top_key("build[1]/test"), "build"); | |
| 2792 | assert_eq!(top_key("build/inner[0]/test"), "build"); | |
| 2793 | assert_eq!(call_prefix("build", Some(1)), "build[1]"); | |
| 2794 | assert_eq!(call_prefix("build/inner", None), "build/inner"); | |
| 2795 | } | |
| 2796 | ||
| 2797 | #[test] | |
| 2798 | fn a_run_connects_ten_levels_of_workflows_and_fifty_reusable_ones() { | |
| 2799 | assert!(call_allowed(1, &[], "a.yml").is_ok()); | |
| 2800 | assert!(call_allowed(9, &[], "a.yml").is_ok()); | |
| 2801 | let deep = call_allowed(10, &[], "a.yml").unwrap_err(); | |
| 2802 | assert!(deep.contains("at most ten") && deep.contains("nine levels"), "{deep}"); | |
| 2803 | let names: Vec<String> = (0..50).map(|n| format!("w{n}.yml")).collect(); | |
| 2804 | let fifty: Vec<&str> = names.iter().map(String::as_str).collect(); | |
| 2805 | // A workflow called again, as by each combination of a matrix, is one. | |
| 2806 | assert!(call_allowed(1, &fifty, "w3.yml").is_ok()); | |
| 2807 | assert!(call_allowed(1, &fifty, "another.yml").unwrap_err().contains("at most 50 different")); | |
| 2808 | } | |
| 2809 | ||
| 2810 | #[test] | |
| 2811 | fn a_matrixs_calls_are_named_by_the_caller_and_its_values() { | |
| 2812 | let workflow = super::workflow::parse( | |
| 2813 | "on: push\njobs:\n build:\n name: Build\n strategy:\n matrix: { os: [linux], node: [20] }\n uses: ./.g1t/workflows/b.yml\n plain:\n uses: ./.g1t/workflows/b.yml\n test:\n name: Test ${{ matrix.os }}\n strategy:\n matrix: { os: [linux] }\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]", | |
| 2814 | ) | |
| 2815 | .unwrap(); | |
| 2816 | let job = |id: &str| workflow.jobs.iter().find(|job| job.id == id).unwrap(); | |
| 2817 | let combination: Map<String, Value> = serde_json::from_value(json!({ "os": "linux", "node": 20 })).unwrap(); | |
| 2818 | let mut contexts = Map::new(); | |
| 2819 | contexts.insert("matrix".into(), Value::Object(combination.clone())); | |
| 2820 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 2821 | assert_eq!(shown_name(job("build"), None, &combination, &scope), "Build (linux, 20)"); | |
| 2822 | assert_eq!(shown_name(job("plain"), None, &Map::new(), &scope), "plain"); | |
| 2823 | // A called workflow's job, under the call that made it. | |
| 2824 | assert_eq!(shown_name(job("plain"), Some("Build (linux, 20)"), &Map::new(), &scope), "Build (linux, 20) / plain"); | |
| 2825 | assert_eq!(shown_name(job("test"), Some("Build"), &combination, &scope), "Build / Test linux"); | |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 2826 | } |
| 2827 | ||
| 2828 | #[test] | |
| 2829 | fn a_job_keeps_twenty_steps_summaries_of_a_mebibyte_each() { | |
| 2830 | assert!(summary_fits(0, None, 10)); | |
| 2831 | assert!(!summary_fits(0, None, 0), "nothing to keep"); | |
| 2832 | assert!(!summary_fits(MAX_SUMMARIES, None, 10), "a twenty-first step's summary is dropped"); | |
| 2833 | assert!(summary_fits(MAX_SUMMARIES, Some(10), 10), "a step that has one may add to it"); | |
| 2834 | assert!(summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 10)); | |
| 2835 | assert!(!summary_fits(1, Some(MAX_SUMMARY_BYTES - 10), 11)); | |
| 2836 | assert!(!summary_fits(0, None, MAX_SUMMARY_BYTES + 1)); | |
| 2837 | } | |
| 2838 | ||
| 2839 | #[test] | |
| 2840 | fn a_debug_rerun_sets_what_github_sets() { | |
| 2841 | let mut variables = Map::new(); | |
| 2842 | let mut runner = json!({ "name": "g1t", "debug": "" }); | |
| 2843 | debug_logging(&mut variables, &mut runner); | |
| 2844 | assert_eq!(variables["RUNNER_DEBUG"], "1"); | |
| 2845 | assert_eq!(variables["ACTIONS_STEP_DEBUG"], "true"); | |
| 2846 | assert_eq!(variables["ACTIONS_RUNNER_DEBUG"], "true"); | |
| 2847 | assert_eq!(runner["debug"], Value::String("1".into())); | |
| 2848 | assert_eq!(runner["name"], "g1t"); | |
| 2849 | } | |
| 2850 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.