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.
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1 | //! Keeping runs safe: a repository's choices for its workflows (what a |
| 2 | //! job's token may do when its workflow says nothing, and which pull | |
| 3 | //! requests' runs wait for approval), environments' protection rules, the | |
| 4 | //! reviews of the jobs they hold, and approving a pull request's run. | |
| 5 | //! | |
| 6 | //! - A job that names an environment with rules is `pending` once its needs | |
| 7 | //! are done: until a reviewer approves it (when the environment has | |
| 8 | //! reviewers), its wait timer has run out (when it has one), and only on | |
| 9 | //! a ref the environment lets deploy. Its secrets are read when it | |
| 10 | //! starts, so the environment's are never handed out before then. One | |
| 11 | //! review approves every job of the run attempt that names the | |
| 12 | //! environment. | |
| 13 | //! - A run of a pull request from outside the workspace may wait as | |
| 14 | //! `action_required` until someone with the Write role approves it, by | |
| 15 | //! the repository's approval policy. | |
| 16 | ||
| 17 | use g1t_actions::filter::Pattern; | |
| 18 | use g1t_actions::permissions::TokenDefault; | |
| 19 | use g1t_contracts::access::{self, AccessSource, Capability, CollaboratorPermissionArgs, PermissionInfo}; | |
| 20 | use g1t_contracts::actions::{ | |
| 21 | APPROVAL_POLICIES, ActionsSettings, ActionsSettingsArgs, SetWorkspaceActionsSettingsArgs, WorkspaceActionsSettings, | |
| 22 | WorkspaceActionsSettingsArgs, BranchPattern, DeleteEnvironmentArgs, Environment, EnvironmentReviewer, | |
| 23 | EnvironmentsArgs, MAX_ENVIRONMENT_REVIEWERS, MAX_WAIT_MINUTES, PendingDeployment, PendingDeploymentsArgs, ReviewDeploymentsArgs, | |
| 24 | RunActionArgs, RunApproval, SetActionsSettingsArgs, SetEnvironmentArgs, WorkflowRun, | |
| 25 | }; | |
| 26 | use g1t_contracts::identity::AGENT_ID; | |
| 27 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 28 | use g1t_contracts::time::rfc3339; | |
| 29 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| 30 | use g1t_kit::now_ms; | |
| 31 | use serde::Deserialize; | |
| 32 | use serde_json::{Value, json}; | |
| 33 | use worker::Result; | |
| 34 | ||
| 35 | use crate::plan::{JobRow, RunRow}; | |
| 36 | use crate::{Actions, check, fail, optional}; | |
| 37 | ||
| 38 | /// What the approval policy is when a repository chose none. | |
| 39 | pub const DEFAULT_APPROVAL_POLICY: &str = "outside_contributors"; | |
| 40 | ||
| 41 | fn now() -> String { | |
| 42 | rfc3339(now_ms()) | |
| 43 | } | |
| 44 | ||
| 45 | /// A repository's own choices (migrations 0006 and 0007); null is unchosen. | |
| 46 | #[derive(Default, Deserialize)] | |
| 47 | struct SettingsRow { | |
| 48 | default_permissions: Option<String>, | |
| 49 | approval_policy: Option<String>, | |
| 50 | can_approve_pulls: Option<u32>, | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 51 | /// Migration 0008: who may use its actions and reusable workflows. |
| 52 | #[serde(default)] | |
| 53 | access_level: Option<String>, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 54 | } |
| 55 | ||
| 56 | #[derive(Deserialize)] | |
| 57 | struct WorkspaceRow { | |
| 58 | default_permissions: String, | |
| 59 | max_permissions: String, | |
| 60 | can_approve_pulls: u32, | |
| 61 | } | |
| 62 | ||
| 63 | /// What a workflow without `permissions:` gets: the repository's choice, | |
| 64 | /// else `write` for a repository made before restricted tokens | |
| 65 | /// (`grandfathered`), else its workspace's default; never more than the | |
| 66 | /// workspace's maximum. | |
| 67 | pub(crate) fn effective_default(chosen: Option<TokenDefault>, grandfathered: bool, workspace_default: TokenDefault, maximum: TokenDefault) -> TokenDefault { | |
| 68 | let wanted = chosen.unwrap_or(if grandfathered { TokenDefault::Permissive } else { workspace_default }); | |
| 69 | if maximum == TokenDefault::Restricted { TokenDefault::Restricted } else { wanted } | |
| 70 | } | |
| 71 | ||
| 72 | /// The workspace's policy, or its defaults. | |
| 73 | fn workspace_policy(row: Option<WorkspaceRow>) -> WorkspaceActionsSettings { | |
| 74 | match row { | |
| 75 | Some(row) => WorkspaceActionsSettings { | |
| 76 | default_permissions: row.default_permissions, | |
| 77 | max_permissions: row.max_permissions, | |
| 78 | can_approve_pull_requests: row.can_approve_pulls != 0, | |
| 79 | }, | |
| 80 | None => WorkspaceActionsSettings { | |
| 81 | default_permissions: TokenDefault::Restricted.as_str().to_owned(), | |
| 82 | max_permissions: TokenDefault::Permissive.as_str().to_owned(), | |
| 83 | can_approve_pull_requests: false, | |
| 84 | }, | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | #[derive(Clone, Deserialize)] | |
| 89 | struct EnvRow { | |
| 90 | name: String, | |
| 91 | reviewers: String, | |
| 92 | prevent_self_review: u32, | |
| 93 | wait_minutes: u32, | |
| 94 | branch_policy: String, | |
| 95 | branch_patterns: String, | |
| 96 | admins_bypass: u32, | |
| 97 | updated_at: String, | |
| 98 | updated_by: Option<String>, | |
| 99 | } | |
| 100 | ||
| 101 | impl EnvRow { | |
| 102 | fn view(&self) -> Environment { | |
| 103 | Environment { | |
| 104 | name: self.name.clone(), | |
| 105 | reviewers: serde_json::from_str(&self.reviewers).unwrap_or_default(), | |
| 106 | prevent_self_review: self.prevent_self_review != 0, | |
| 107 | wait_minutes: self.wait_minutes, | |
| 108 | branch_policy: self.branch_policy.clone(), | |
| 109 | branch_patterns: serde_json::from_str(&self.branch_patterns).unwrap_or_default(), | |
| 110 | admins_bypass: self.admins_bypass != 0, | |
| 111 | protected: true, | |
| 112 | updated_at: Some(self.updated_at.clone()), | |
| 113 | updated_by: self.updated_by.clone(), | |
| 114 | } | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | /// An environment no rules hold. | |
| 119 | fn unprotected(name: &str) -> Environment { | |
| 120 | Environment { | |
| 121 | name: name.to_owned(), | |
| 122 | reviewers: Vec::new(), | |
| 123 | prevent_self_review: false, | |
| 124 | wait_minutes: 0, | |
| 125 | branch_policy: "all".to_owned(), | |
| 126 | branch_patterns: Vec::new(), | |
| 127 | admins_bypass: true, | |
| 128 | protected: false, | |
| 129 | updated_at: None, | |
| 130 | updated_by: None, | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | #[derive(Clone, Deserialize)] | |
| 135 | struct GateRow { | |
| 136 | environment: String, | |
| 137 | state: String, | |
| 138 | needs_review: u32, | |
| 139 | wait_until: Option<String>, | |
| 140 | reviewed_by: Option<String>, | |
| 141 | comment: Option<String>, | |
| 142 | reviewed_at: Option<String>, | |
| 143 | } | |
| 144 | ||
| 145 | /// An environment's name as rules and secrets keep it: lowercase, up to 40 | |
| 146 | /// letters, digits, `-` and `_`. | |
| 147 | pub(crate) fn environment_name(name: &str) -> std::result::Result<String, String> { | |
| 148 | let lower = name.trim().to_ascii_lowercase(); | |
| 149 | if lower.is_empty() || lower.len() > 40 || !lower.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { | |
| 150 | return Err(format!("`{}` is not an environment's name: up to 40 letters, digits, - and _.", name.trim())); | |
| 151 | } | |
| 152 | Ok(lower) | |
| 153 | } | |
| 154 | ||
| 155 | /// Whether `git_ref` may deploy to an environment by its `policy`. | |
| 156 | /// `protected` says whether the branch is protected, for `protected`. | |
| 157 | pub(crate) fn branch_allowed(policy: &str, patterns: &[BranchPattern], git_ref: &str, protected: bool, environment: &str) -> std::result::Result<(), String> { | |
| 158 | let (kind, name) = match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) { | |
| 159 | (Some(branch), _) => ("branch", branch), | |
| 160 | (_, Some(tag)) => ("tag", tag), | |
| 161 | _ => ("ref", git_ref), | |
| 162 | }; | |
| 163 | match policy { | |
| 164 | "protected" if kind == "branch" && protected => Ok(()), | |
| 165 | "protected" => Err(format!("{environment} takes deployments from protected branches only, and `{name}` is not one.")), | |
| 166 | "selected" => { | |
| 167 | let allowed = patterns | |
| 168 | .iter() | |
| 169 | .filter(|pattern| pattern.kind == kind) | |
| 170 | .any(|pattern| Pattern::parse(&pattern.name).matches(name)); | |
| 171 | if allowed { | |
| 172 | Ok(()) | |
| 173 | } else { | |
| 174 | Err(format!("The {kind} `{name}` may not deploy to {environment}: its deployment rules list the branches and tags that may.")) | |
| 175 | } | |
| 176 | } | |
| 177 | _ => Ok(()), | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | /// How someone may decide on an environment's jobs. | |
| 182 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 183 | pub(crate) enum Reviewer { | |
| 184 | /// One of its reviewers. | |
| 185 | Listed, | |
| 186 | /// An admin, past its rules, which also skips its wait timer. | |
| 187 | AdminBypass, | |
| 188 | } | |
| 189 | ||
| 190 | /// Whether `username` (in `teams`, an admin or not) may approve or reject | |
| 191 | /// the environment's jobs in a run `started_by` began. | |
| 192 | pub(crate) fn may_review( | |
| 193 | environment: &Environment, | |
| 194 | username: &str, | |
| 195 | teams: &[String], | |
| 196 | admin: bool, | |
| 197 | started_by: Option<&str>, | |
| 198 | ) -> std::result::Result<Reviewer, String> { | |
| 199 | let listed = environment.reviewers.iter().any(|reviewer| match reviewer.kind.as_str() { | |
| 200 | "team" => teams.iter().any(|team| team.eq_ignore_ascii_case(&reviewer.name)), | |
| 201 | _ => reviewer.name.eq_ignore_ascii_case(username), | |
| 202 | }); | |
| 203 | let started = started_by.is_some_and(|by| by.eq_ignore_ascii_case(username)); | |
| 204 | if listed && !(environment.prevent_self_review && started) { | |
| 205 | return Ok(Reviewer::Listed); | |
| 206 | } | |
| 207 | if admin && environment.admins_bypass { | |
| 208 | return Ok(Reviewer::AdminBypass); | |
| 209 | } | |
| 210 | Err(if listed { | |
| 211 | format!("You started this run, and {} does not let whoever started a run approve its deployments.", environment.name) | |
| 212 | } else if environment.reviewers.is_empty() { | |
| 213 | format!("{} has no reviewers: its jobs wait for its timer. Only an admin can start them sooner.", environment.name) | |
| 214 | } else { | |
| 215 | format!("You are not one of {}'s reviewers.", environment.name) | |
| 216 | }) | |
| 217 | } | |
| 218 | ||
| 219 | /// Whether a gate lets its jobs start at `now`. | |
| 220 | pub(crate) fn gate_open(state: &str, needs_review: bool, wait_until: Option<&str>, now: &str) -> bool { | |
| 221 | state != "rejected" && (!needs_review || state == "approved") && wait_until.is_none_or(|until| until <= now) | |
| 222 | } | |
| 223 | ||
| 224 | /// Why a pull request's run waits for approval, if it does, by `policy`: | |
| 225 | /// `member` of the workspace, whether they `can_push`, and whether this is | |
| 226 | /// their `first_time` (no pull request of theirs merged here). | |
| 227 | pub(crate) fn approval_reason(policy: &str, member: bool, can_push: bool, first_time: bool) -> Option<&'static str> { | |
| 228 | if member { | |
| 229 | return None; | |
| 230 | } | |
| 231 | match policy { | |
| 232 | "all_external_contributors" => Some("is not a member of the workspace"), | |
| 233 | "first_time_contributors" => first_time.then_some("has not had a pull request merged here yet"), | |
| 234 | _ if !can_push => Some("cannot push to the repository"), | |
| 235 | _ => first_time.then_some("has not had a pull request merged here yet"), | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | /// What a job waits for at its environment, in words. | |
| 240 | fn held_reason(environment: &str, needs_review: bool, wait_until: Option<&str>) -> String { | |
| 241 | match (needs_review, wait_until) { | |
| 242 | (true, Some(until)) => format!("Waiting for a review to deploy to {environment}, and for its wait timer, until {until}."), | |
| 243 | (true, None) => format!("Waiting for a review to deploy to {environment}."), | |
| 244 | (false, Some(until)) => format!("Waiting for {environment}'s wait timer, until {until}."), | |
| 245 | (false, None) => format!("Waiting to deploy to {environment}."), | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | /// What an environment's rules say about a job that names it. | |
| 250 | pub(crate) enum Gate { | |
| 251 | /// No rules hold it: it starts. | |
| 252 | Open, | |
| 253 | /// It waits, for this. | |
| 254 | Held(String), | |
| 255 | /// It may not deploy there. | |
| 256 | Refused(String), | |
| 257 | } | |
| 258 | ||
| 259 | impl Actions { | |
| 260 | // --- The repository's and workspace's choices ------------------------------- | |
| 261 | ||
| 262 | async fn repo_choices(&self, repo_id: &str) -> Result<SettingsRow> { | |
| 263 | Ok(self | |
| 264 | .db | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 265 | .prepare("SELECT default_permissions, approval_policy, can_approve_pulls, access_level FROM repo_settings WHERE repo_id = ?") |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 266 | .bind(&[repo_id.into()])? |
| 267 | .first::<SettingsRow>(None) | |
| 268 | .await? | |
| 269 | .unwrap_or_default()) | |
| 270 | } | |
| 271 | ||
| 272 | /// A workspace's policy for its repositories' tokens, or the defaults. | |
| 273 | pub(crate) async fn workspace_settings(&self, workspace: &str) -> Result<WorkspaceActionsSettings> { | |
| 274 | let row = self | |
| 275 | .db | |
| 276 | .prepare("SELECT default_permissions, max_permissions, can_approve_pulls FROM workspace_actions_settings WHERE namespace = ?") | |
| 277 | .bind(&[workspace.to_lowercase().into()])? | |
| 278 | .first::<WorkspaceRow>(None) | |
| 279 | .await?; | |
| 280 | Ok(workspace_policy(row)) | |
| 281 | } | |
| 282 | ||
| 283 | /// Whether `repo` was made before restricted tokens began, and so keeps | |
| 284 | /// read and write until someone chooses otherwise. | |
| 285 | async fn grandfathered(&self, repo: &Repo) -> Result<bool> { | |
| 286 | #[derive(Deserialize)] | |
| 287 | struct Since { | |
| 288 | value: String, | |
| 289 | } | |
| 290 | let since = self | |
| 291 | .db | |
| 292 | .prepare("SELECT value FROM actions_meta WHERE key = 'restricted_since'") | |
| 293 | .first::<Since>(None) | |
| 294 | .await?; | |
| 295 | Ok(since.is_some_and(|since| !repo.created_at.is_empty() && repo.created_at.as_str() < since.value.as_str())) | |
| 296 | } | |
| 297 | ||
| 298 | /// A repository's settings as they hold, its workspace's taken in. | |
| 299 | pub(crate) async fn repo_settings(&self, repo: &Repo) -> Result<ActionsSettings> { | |
| 300 | let row = self.repo_choices(&repo.id).await?; | |
| 301 | let workspace = self.workspace_settings(&repo.namespace).await?; | |
| 302 | let chosen = row.default_permissions.as_deref().and_then(TokenDefault::parse); | |
| 303 | let default = effective_default( | |
| 304 | chosen, | |
| 305 | self.grandfathered(repo).await?, | |
| 306 | TokenDefault::parse(&workspace.default_permissions).unwrap_or_default(), | |
| 307 | TokenDefault::parse(&workspace.max_permissions).unwrap_or(TokenDefault::Permissive), | |
| 308 | ); | |
| 309 | Ok(ActionsSettings { | |
| 310 | default_permissions: default.as_str().to_owned(), | |
| 311 | default_chosen: chosen.is_some(), | |
| 312 | max_permissions: workspace.max_permissions.clone(), | |
| 313 | approval_policy: row.approval_policy.unwrap_or_else(|| DEFAULT_APPROVAL_POLICY.to_owned()), | |
| 314 | can_approve_pull_requests: workspace.can_approve_pull_requests && row.can_approve_pulls == Some(1), | |
| 315 | workspace_allows_pull_requests: workspace.can_approve_pull_requests, | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 316 | access_level: row.access_level.as_deref().and_then(g1t_contracts::actions::access_level).unwrap_or("none").to_owned(), |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 317 | }) |
| 318 | } | |
| 319 | ||
| 320 | /// What a workflow without `permissions:` gets in the repository, and | |
| 321 | /// whether its jobs may open and approve pull requests. | |
| 322 | pub(crate) async fn token_policy(&self, repo_id: &str) -> Result<(TokenDefault, bool)> { | |
| 323 | let Some((repo, _)) = self.repo_by_id(repo_id).await? else { | |
| 324 | return Ok((TokenDefault::Restricted, false)); | |
| 325 | }; | |
| 326 | let settings = self.repo_settings(&repo).await?; | |
| 327 | Ok((TokenDefault::parse(&settings.default_permissions).unwrap_or_default(), settings.can_approve_pull_requests)) | |
| 328 | } | |
| 329 | ||
| 330 | /// The approval policy of a repository. | |
| 331 | pub(crate) async fn approval_policy(&self, repo_id: &str) -> Result<String> { | |
| 332 | Ok(self.repo_choices(repo_id).await?.approval_policy.unwrap_or_else(|| DEFAULT_APPROVAL_POLICY.to_owned())) | |
| 333 | } | |
| 334 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 335 | /// Who may use a repository's actions and reusable workflows when it is |
| 336 | /// private: `none` or `organization`. | |
| 337 | pub(crate) async fn access_level_of(&self, repo_id: &str) -> Result<&'static str> { | |
| 338 | Ok(self.repo_choices(repo_id).await?.access_level.as_deref().and_then(g1t_contracts::actions::access_level).unwrap_or("none")) | |
| 339 | } | |
| 340 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 341 | pub async fn actions_settings(&self, a: ActionsSettingsArgs) -> Result<Outcome<ActionsSettings>> { |
| 342 | let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else { | |
| 343 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); | |
| 344 | }; | |
| 345 | Ok(Outcome::Ok(self.repo_settings(&repo).await?)) | |
| 346 | } | |
| 347 | ||
| 348 | pub async fn set_actions_settings(&self, a: SetActionsSettingsArgs) -> Result<Outcome<ActionsSettings>> { | |
| 349 | if let Some(refused) = refuse_job_token(&a.actor) { | |
| 350 | return Ok(refused); | |
| 351 | } | |
| 352 | let repo = check!(self.may(&a.actor, &a.repo, Capability::ManageIntegrations).await?); | |
| 353 | let mut row = self.repo_choices(&repo.id).await?; | |
| 354 | let workspace = self.workspace_settings(&repo.namespace).await?; | |
| 355 | if let Some(default) = a.default_permissions.as_deref().map(str::trim) { | |
| 356 | row.default_permissions = match default { | |
| 357 | "inherit" | "" => None, | |
| 358 | other => match TokenDefault::parse(other) { | |
| 359 | Some(TokenDefault::Permissive) if workspace.max_permissions == "read" => { | |
| 360 | return Ok(fail( | |
| 361 | FailureCode::Forbidden, | |
| 362 | format!("{} holds its repositories' tokens to read-only: an owner can change that in the workspace's Actions settings.", repo.namespace), | |
| 363 | )); | |
| 364 | } | |
| 365 | Some(parsed) => Some(parsed.as_str().to_owned()), | |
| 366 | None => return Ok(fail(FailureCode::Invalid, "default_permissions is read, write or inherit.")), | |
| 367 | }, | |
| 368 | }; | |
| 369 | } | |
| 370 | if let Some(policy) = &a.approval_policy { | |
| 371 | let policy = policy.trim(); | |
| 372 | if !APPROVAL_POLICIES.contains(&policy) { | |
| 373 | return Ok(fail(FailureCode::Invalid, format!("approval_policy is one of {}.", APPROVAL_POLICIES.join(", ")))); | |
| 374 | } | |
| 375 | row.approval_policy = Some(policy.to_owned()); | |
| 376 | } | |
| 377 | if let Some(allow) = a.can_approve_pull_requests { | |
| 378 | if allow && !workspace.can_approve_pull_requests { | |
| 379 | return Ok(fail( | |
| 380 | FailureCode::Forbidden, | |
| 381 | format!("{} does not let its repositories' jobs open or approve pull requests: an owner can allow it in the workspace's Actions settings.", repo.namespace), | |
| 382 | )); | |
| 383 | } | |
| 384 | row.can_approve_pulls = Some(u32::from(allow)); | |
| 385 | } | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 386 | if let Some(level) = &a.access_level { |
| 387 | match g1t_contracts::actions::access_level(level) { | |
| 388 | Some(level) => row.access_level = Some(level.to_owned()), | |
| 389 | None => return Ok(fail(FailureCode::Invalid, "access_level is none or organization.")), | |
| 390 | } | |
| 391 | } | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 392 | // 0006's artifact retention is kept, or its default for a new row. |
| 393 | self.db | |
| 394 | .prepare( | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 395 | "INSERT INTO repo_settings (repo_id, artifact_retention_days, default_permissions, approval_policy, can_approve_pulls, access_level, updated_at, updated_by) |
| 396 | VALUES (?1, ?7, ?2, ?3, ?4, ?8, ?5, ?6) | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 397 | ON CONFLICT (repo_id) DO UPDATE SET default_permissions = ?2, approval_policy = ?3, can_approve_pulls = ?4, |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 398 | access_level = ?8, updated_at = ?5, updated_by = ?6", |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 399 | ) |
| 400 | .bind(&[ | |
| 401 | repo.id.as_str().into(), | |
| 402 | optional(row.default_permissions.as_deref()), | |
| 403 | optional(row.approval_policy.as_deref()), | |
| 404 | row.can_approve_pulls.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 405 | now().into(), | |
| 406 | a.actor.username.as_str().into(), | |
| 407 | g1t_contracts::actions::ARTIFACT_RETENTION_DEFAULT_DAYS.into(), | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 408 | optional(row.access_level.as_deref()), |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 409 | ])? |
| 410 | .run() | |
| 411 | .await?; | |
| 412 | Ok(Outcome::Ok(self.repo_settings(&repo).await?)) | |
| 413 | } | |
| 414 | ||
| 415 | pub async fn workspace_actions_settings(&self, a: WorkspaceActionsSettingsArgs) -> Result<Outcome<WorkspaceActionsSettings>> { | |
| 416 | let workspace = a.workspace.trim().to_lowercase(); | |
| 417 | if !a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 418 | return Ok(fail(FailureCode::NotFound, "There is no such workspace.")); | |
| 419 | } | |
| 420 | Ok(Outcome::Ok(self.workspace_settings(&workspace).await?)) | |
| 421 | } | |
| 422 | ||
| 423 | pub async fn set_workspace_actions_settings(&self, a: SetWorkspaceActionsSettingsArgs) -> Result<Outcome<WorkspaceActionsSettings>> { | |
| 424 | if let Some(refused) = refuse_job_token(&a.actor) { | |
| 425 | return Ok(refused); | |
| 426 | } | |
| 427 | let workspace = a.workspace.trim().to_lowercase(); | |
| 428 | if a.actor.kind != PrincipalKind::User || a.actor.role_in(&workspace) != Some(g1t_contracts::Role::Owner) { | |
| 429 | return Ok(fail(FailureCode::Forbidden, format!("Only an owner of {workspace} can change its Actions settings."))); | |
| 430 | } | |
| 431 | let mut settings = self.workspace_settings(&workspace).await?; | |
| 432 | let level = |text: &str, field: &str| -> std::result::Result<String, String> { | |
| 433 | TokenDefault::parse(text.trim()).map(|parsed| parsed.as_str().to_owned()).ok_or_else(|| format!("{field} is read or write.")) | |
| 434 | }; | |
| 435 | if let Some(text) = &a.default_permissions { | |
| 436 | match level(text, "default_permissions") { | |
| 437 | Ok(parsed) => settings.default_permissions = parsed, | |
| 438 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 439 | } | |
| 440 | } | |
| 441 | if let Some(text) = &a.max_permissions { | |
| 442 | match level(text, "max_permissions") { | |
| 443 | Ok(parsed) => settings.max_permissions = parsed, | |
| 444 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 445 | } | |
| 446 | } | |
| 447 | if settings.max_permissions == "read" { | |
| 448 | settings.default_permissions = "read".to_owned(); | |
| 449 | } | |
| 450 | if let Some(allow) = a.can_approve_pull_requests { | |
| 451 | settings.can_approve_pull_requests = allow; | |
| 452 | } | |
| 453 | self.db | |
| 454 | .prepare( | |
| 455 | "INSERT INTO workspace_actions_settings (namespace, default_permissions, max_permissions, can_approve_pulls, updated_at, updated_by) | |
| 456 | VALUES (?1, ?2, ?3, ?4, ?5, ?6) | |
| 457 | ON CONFLICT (namespace) DO UPDATE SET default_permissions = ?2, max_permissions = ?3, can_approve_pulls = ?4, | |
| 458 | updated_at = ?5, updated_by = ?6", | |
| 459 | ) | |
| 460 | .bind(&[ | |
| 461 | workspace.as_str().into(), | |
| 462 | settings.default_permissions.as_str().into(), | |
| 463 | settings.max_permissions.as_str().into(), | |
| 464 | u32::from(settings.can_approve_pull_requests).into(), | |
| 465 | now().into(), | |
| 466 | a.actor.username.as_str().into(), | |
| 467 | ])? | |
| 468 | .run() | |
| 469 | .await?; | |
| 470 | Ok(Outcome::Ok(settings)) | |
| 471 | } | |
| 472 | ||
| 473 | // --- Environments ------------------------------------------------------------- | |
| 474 | ||
| 475 | async fn environment_row(&self, repo_id: &str, name: &str) -> Result<Option<EnvRow>> { | |
| 476 | self.db | |
| 477 | .prepare("SELECT * FROM environments WHERE repo_id = ? AND name = ?") | |
| 478 | .bind(&[repo_id.into(), name.to_ascii_lowercase().into()])? | |
| 479 | .first::<EnvRow>(None) | |
| 480 | .await | |
| 481 | } | |
| 482 | ||
| 483 | /// An environment and its rules; one without rules if it has none. | |
| 484 | pub(crate) async fn environment_rules(&self, repo_id: &str, name: &str) -> Result<Environment> { | |
| 485 | Ok(match self.environment_row(repo_id, name).await? { | |
| 486 | Some(row) => row.view(), | |
| 487 | None => unprotected(&name.to_ascii_lowercase()), | |
| 488 | }) | |
| 489 | } | |
| 490 | ||
| 491 | /// `environments`: every environment the repository's rules, secrets, | |
| 492 | /// workflows or jobs name; or with `name`, the one. | |
| 493 | pub async fn environments(&self, a: EnvironmentsArgs) -> Result<Outcome<Vec<Environment>>> { | |
| 494 | let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else { | |
| 495 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); | |
| 496 | }; | |
| 497 | if let Some(name) = &a.name { | |
| 498 | let name = check!(environment_name(name).map_or_else(|problem| fail(FailureCode::Invalid, problem), Outcome::Ok)); | |
| 499 | return Ok(Outcome::Ok(vec![self.environment_rules(&repo.id, &name).await?])); | |
| 500 | } | |
| 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. | 501 | Ok(Outcome::Ok(self.environment_list(&repo.id).await?)) |
| 502 | } | |
| 503 | ||
| 504 | /// Every environment a repository has: those with rules, and those its | |
| 505 | /// secrets, workflows or jobs name. Protected ones first. | |
| 506 | pub(crate) async fn environment_list(&self, repo_id: &str) -> Result<Vec<Environment>> { | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 507 | let rows = self |
| 508 | .db | |
| 509 | .prepare("SELECT * FROM environments WHERE repo_id = ? ORDER BY name") | |
| 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. | 510 | .bind(&[repo_id.into()])? |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 511 | .all() |
| 512 | .await? | |
| 513 | .results::<EnvRow>()?; | |
| 514 | let mut out: Vec<Environment> = rows.iter().map(EnvRow::view).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. | 515 | let mut named: Vec<String> = self.secret_environments(repo_id).await?; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 516 | #[derive(Deserialize)] |
| 517 | struct Named { | |
| 518 | environment: String, | |
| 519 | } | |
| 520 | let used = self | |
| 521 | .db | |
| 522 | .prepare("SELECT DISTINCT environment FROM jobs WHERE repo_id = ? AND environment IS NOT NULL LIMIT 100") | |
| 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. | 523 | .bind(&[repo_id.into()])? |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 524 | .all() |
| 525 | .await? | |
| 526 | .results::<Named>()?; | |
| 527 | named.extend(used.into_iter().map(|n| n.environment)); | |
| 528 | #[derive(Deserialize)] | |
| 529 | struct Source { | |
| 530 | source: String, | |
| 531 | } | |
| 532 | let sources = self | |
| 533 | .db | |
| 534 | .prepare("SELECT source FROM workflows WHERE repo_id = ? AND error IS NULL") | |
| 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. | 535 | .bind(&[repo_id.into()])? |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 536 | .all() |
| 537 | .await? | |
| 538 | .results::<Source>()?; | |
| 539 | for source in sources { | |
| 540 | let Ok(workflow) = g1t_actions::workflow::parse(&source.source) else { continue }; | |
| 541 | for job in &workflow.jobs { | |
| 542 | let written = match job.raw.get("environment") { | |
| 543 | Some(Value::String(name)) => Some(name.clone()), | |
| 544 | Some(Value::Object(env)) => env.get("name").and_then(Value::as_str).map(str::to_owned), | |
| 545 | _ => None, | |
| 546 | }; | |
| 547 | if let Some(name) = written.filter(|name| !name.contains("${{")) { | |
| 548 | named.push(name); | |
| 549 | } | |
| 550 | } | |
| 551 | } | |
| 552 | for name in named { | |
| 553 | if let Ok(name) = environment_name(&name) | |
| 554 | && !out.iter().any(|env| env.name == name) | |
| 555 | { | |
| 556 | out.push(unprotected(&name)); | |
| 557 | } | |
| 558 | } | |
| 559 | out.sort_by(|a, b| b.protected.cmp(&a.protected).then(a.name.cmp(&b.name))); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 560 | Ok(out) |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 561 | } |
| 562 | ||
| 563 | pub async fn set_environment(&self, a: SetEnvironmentArgs) -> Result<Outcome<Environment>> { | |
| 564 | if let Some(refused) = refuse_job_token(&a.actor) { | |
| 565 | return Ok(refused); | |
| 566 | } | |
| 567 | let repo = check!(self.may(&a.actor, &a.repo, Capability::ManageIntegrations).await?); | |
| 568 | let name = check!(environment_name(&a.name).map_or_else(|problem| fail(FailureCode::Invalid, problem), Outcome::Ok)); | |
| 569 | let mut env = self.environment_rules(&repo.id, &name).await?; | |
| 570 | if let Some(reviewers) = a.reviewers { | |
| 571 | let reviewers = check!(self.valid_reviewers(&repo, reviewers).await?); | |
| 572 | env.reviewers = reviewers; | |
| 573 | } | |
| 574 | if let Some(flag) = a.prevent_self_review { | |
| 575 | env.prevent_self_review = flag; | |
| 576 | } | |
| 577 | if let Some(minutes) = a.wait_minutes { | |
| 578 | if minutes > MAX_WAIT_MINUTES { | |
| 579 | return Ok(fail(FailureCode::Invalid, format!("A wait timer is at most {MAX_WAIT_MINUTES} minutes (30 days)."))); | |
| 580 | } | |
| 581 | env.wait_minutes = minutes; | |
| 582 | } | |
| 583 | if let Some(policy) = a.branch_policy { | |
| 584 | let policy = policy.trim().to_owned(); | |
| 585 | if !["all", "protected", "selected"].contains(&policy.as_str()) { | |
| 586 | return Ok(fail(FailureCode::Invalid, "branch_policy is all, protected or selected.")); | |
| 587 | } | |
| 588 | env.branch_policy = policy; | |
| 589 | } | |
| 590 | if let Some(patterns) = a.branch_patterns { | |
| 591 | let mut clean: Vec<BranchPattern> = Vec::new(); | |
| 592 | for pattern in patterns { | |
| 593 | let name = pattern.name.trim().to_owned(); | |
| 594 | let kind = pattern.kind.trim().to_ascii_lowercase(); | |
| 595 | if name.is_empty() || name.len() > 255 { | |
| 596 | return Ok(fail(FailureCode::Invalid, "A branch or tag pattern is 1 to 255 characters.")); | |
| 597 | } | |
| 598 | if kind != "branch" && kind != "tag" { | |
| 599 | return Ok(fail(FailureCode::Invalid, format!("`{name}`: a pattern's type is branch or tag."))); | |
| 600 | } | |
| 601 | if !clean.iter().any(|known| known.name == name && known.kind == kind) { | |
| 602 | clean.push(BranchPattern { name, kind }); | |
| 603 | } | |
| 604 | } | |
| 605 | if clean.len() > 50 { | |
| 606 | return Ok(fail(FailureCode::Invalid, "An environment lists at most 50 branch and tag patterns.")); | |
| 607 | } | |
| 608 | env.branch_patterns = clean; | |
| 609 | } | |
| 610 | if let Some(flag) = a.admins_bypass { | |
| 611 | env.admins_bypass = flag; | |
| 612 | } | |
| 613 | let at = now(); | |
| 614 | self.db | |
| 615 | .prepare( | |
| 616 | "INSERT INTO environments (repo_id, name, reviewers, prevent_self_review, wait_minutes, branch_policy, branch_patterns, | |
| 617 | admins_bypass, created_at, updated_at, updated_by) | |
| 618 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10) | |
| 619 | ON CONFLICT (repo_id, name) DO UPDATE SET reviewers = ?3, prevent_self_review = ?4, wait_minutes = ?5, | |
| 620 | branch_policy = ?6, branch_patterns = ?7, admins_bypass = ?8, updated_at = ?9, updated_by = ?10", | |
| 621 | ) | |
| 622 | .bind(&[ | |
| 623 | repo.id.as_str().into(), | |
| 624 | name.as_str().into(), | |
| 625 | serde_json::to_string(&env.reviewers)?.into(), | |
| 626 | u32::from(env.prevent_self_review).into(), | |
| 627 | env.wait_minutes.into(), | |
| 628 | env.branch_policy.as_str().into(), | |
| 629 | serde_json::to_string(&env.branch_patterns)?.into(), | |
| 630 | u32::from(env.admins_bypass).into(), | |
| 631 | at.as_str().into(), | |
| 632 | a.actor.username.as_str().into(), | |
| 633 | ])? | |
| 634 | .run() | |
| 635 | .await?; | |
| 636 | Ok(Outcome::Ok(self.environment_rules(&repo.id, &name).await?)) | |
| 637 | } | |
| 638 | ||
| 639 | pub async fn delete_environment(&self, a: DeleteEnvironmentArgs) -> Result<Outcome<bool>> { | |
| 640 | if let Some(refused) = refuse_job_token(&a.actor) { | |
| 641 | return Ok(refused); | |
| 642 | } | |
| 643 | let repo = check!(self.may(&a.actor, &a.repo, Capability::ManageIntegrations).await?); | |
| 644 | let name = check!(environment_name(&a.name).map_or_else(|problem| fail(FailureCode::Invalid, problem), Outcome::Ok)); | |
| 645 | let removed = self | |
| 646 | .db | |
| 647 | .prepare("DELETE FROM environments WHERE repo_id = ? AND name = ? RETURNING name") | |
| 648 | .bind(&[repo.id.as_str().into(), name.as_str().into()])? | |
| 649 | .first::<Value>(None) | |
| 650 | .await?; | |
| 651 | if removed.is_none() { | |
| 652 | return Ok(fail(FailureCode::NotFound, format!("{name} has no protection rules."))); | |
| 653 | } | |
| 654 | Ok(Outcome::Ok(true)) | |
| 655 | } | |
| 656 | ||
| 657 | /// Reviewers as given, checked: at most six, each a person with access | |
| 658 | /// to the repository or a team of its workspace. | |
| 659 | async fn valid_reviewers(&self, repo: &Repo, given: Vec<EnvironmentReviewer>) -> Result<Outcome<Vec<EnvironmentReviewer>>> { | |
| 660 | if given.len() > MAX_ENVIRONMENT_REVIEWERS { | |
| 661 | return Ok(fail(FailureCode::Invalid, format!("An environment has at most {MAX_ENVIRONMENT_REVIEWERS} reviewers."))); | |
| 662 | } | |
| 663 | let Some(ws) = self.workspace_actor(&repo.namespace).await? else { | |
| 664 | return Ok(fail(FailureCode::NotFound, "There is no such workspace.")); | |
| 665 | }; | |
| 666 | let mut out: Vec<EnvironmentReviewer> = Vec::new(); | |
| 667 | for reviewer in given { | |
| 668 | let kind = reviewer.kind.trim().to_ascii_lowercase(); | |
| 669 | let name = reviewer.name.trim().trim_start_matches('@').to_ascii_lowercase(); | |
| 670 | match kind.as_str() { | |
| 671 | "user" => { | |
| 672 | let permission: Outcome<PermissionInfo> = g1t_kit::call( | |
| 673 | &self.identity, | |
| 674 | "collaborator_permission", | |
| 675 | &CollaboratorPermissionArgs { | |
| 676 | viewer: Some(ws.clone()), | |
| 677 | path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, | |
| 678 | username: name.clone(), | |
| 679 | }, | |
| 680 | ) | |
| 681 | .await?; | |
| 682 | if !permission.into_result().ok().is_some_and(|info| info.role.is_some()) { | |
| 683 | return Ok(fail(FailureCode::Invalid, format!("{name} has no access to {}/{}, so cannot review.", repo.namespace, repo.name))); | |
| 684 | } | |
| 685 | } | |
| 686 | "team" => { | |
| 687 | let slug = name.rsplit('/').next().unwrap_or(&name).to_owned(); | |
| 688 | if self.team_people(&repo.namespace, &slug).await?.is_none() { | |
| 689 | return Ok(fail(FailureCode::Invalid, format!("{} has no team called {slug}.", repo.namespace))); | |
| 690 | } | |
| 691 | if !out.iter().any(|known| known.kind == "team" && known.name == slug) { | |
| 692 | out.push(EnvironmentReviewer { kind, name: slug }); | |
| 693 | } | |
| 694 | continue; | |
| 695 | } | |
| 696 | _ => return Ok(fail(FailureCode::Invalid, "A reviewer's type is user or team.")), | |
| 697 | } | |
| 698 | if !out.iter().any(|known| known.kind == kind && known.name == name) { | |
| 699 | out.push(EnvironmentReviewer { kind, name }); | |
| 700 | } | |
| 701 | } | |
| 702 | Ok(Outcome::Ok(out)) | |
| 703 | } | |
| 704 | ||
| 705 | /// Everyone in a team of `workspace` and its child teams, by username; | |
| 706 | /// `None` when there is no such team. | |
| 707 | async fn team_people(&self, workspace: &str, slug: &str) -> Result<Option<Vec<String>>> { | |
| 708 | let resolved: Vec<Value> = g1t_kit::call( | |
| 709 | &self.identity, | |
| 710 | "resolve_teams", | |
| 711 | &json!({ "teams": [format!("{workspace}/{slug}")] }), | |
| 712 | ) | |
| 713 | .await | |
| 714 | .unwrap_or_default(); | |
| 715 | Ok(resolved.into_iter().next().map(|team| { | |
| 716 | ["members", "child_members"] | |
| 717 | .iter() | |
| 718 | .flat_map(|key| team[*key].as_array().cloned().unwrap_or_default()) | |
| 719 | .filter_map(|person| person["username"].as_str().map(str::to_lowercase)) | |
| 720 | .collect() | |
| 721 | })) | |
| 722 | } | |
| 723 | ||
| 724 | /// The teams of the environment's reviewers that `username` is in. | |
| 725 | async fn reviewer_teams(&self, workspace: &str, environment: &Environment, username: &str) -> Result<Vec<String>> { | |
| 726 | let mut teams = Vec::new(); | |
| 727 | for reviewer in environment.reviewers.iter().filter(|r| r.kind == "team") { | |
| 728 | if self | |
| 729 | .team_people(workspace, &reviewer.name) | |
| 730 | .await? | |
| 731 | .is_some_and(|people| people.iter().any(|person| person.eq_ignore_ascii_case(username))) | |
| 732 | { | |
| 733 | teams.push(reviewer.name.clone()); | |
| 734 | } | |
| 735 | } | |
| 736 | Ok(teams) | |
| 737 | } | |
| 738 | ||
| 739 | // --- Gates --------------------------------------------------------------------- | |
| 740 | ||
| 741 | /// What `environment`'s rules say about a job of `run` that names it, | |
| 742 | /// recording the run's gate there the first time one of its jobs | |
| 743 | /// reaches it. | |
| 744 | pub(crate) async fn gate(&self, run: &RunRow, environment: &str) -> Result<Gate> { | |
| 745 | let Ok(name) = environment_name(environment) else { | |
| 746 | return Ok(Gate::Open); | |
| 747 | }; | |
| 748 | let Some(row) = self.environment_row(&run.repo_id, &name).await? else { | |
| 749 | return Ok(Gate::Open); | |
| 750 | }; | |
| 751 | let env = row.view(); | |
| 752 | // Which refs may deploy, before anything waits. | |
| 753 | let protected = if env.branch_policy == "protected" { self.branch_protected(run).await? } else { false }; | |
| 754 | if let Err(problem) = branch_allowed(&env.branch_policy, &env.branch_patterns, &run.git_ref, protected, &name) { | |
| 755 | return Ok(Gate::Refused(problem)); | |
| 756 | } | |
| 757 | let needs_review = !env.reviewers.is_empty(); | |
| 758 | if !needs_review && env.wait_minutes == 0 { | |
| 759 | return Ok(Gate::Open); | |
| 760 | } | |
| 761 | let existing = self.gate_row(&run.id, run.attempt as u32, &name).await?; | |
| 762 | let gate = match existing { | |
| 763 | Some(gate) => gate, | |
| 764 | None => { | |
| 765 | let wait_until = (env.wait_minutes > 0).then(|| rfc3339(now_ms() + u64::from(env.wait_minutes) * 60_000)); | |
| 766 | self.db | |
| 767 | .prepare( | |
| 768 | "INSERT OR IGNORE INTO environment_gates (run_id, attempt, environment, repo_id, state, needs_review, wait_until, created_at) | |
| 769 | VALUES (?, ?, ?, ?, 'waiting', ?, ?, ?)", | |
| 770 | ) | |
| 771 | .bind(&[ | |
| 772 | run.id.as_str().into(), | |
| 773 | (run.attempt as u32).into(), | |
| 774 | name.as_str().into(), | |
| 775 | run.repo_id.as_str().into(), | |
| 776 | u32::from(needs_review).into(), | |
| 777 | optional(wait_until.as_deref()), | |
| 778 | now().into(), | |
| 779 | ])? | |
| 780 | .run() | |
| 781 | .await?; | |
| 782 | if needs_review { | |
| 783 | self.ask_reviewers(run, &env).await; | |
| 784 | } | |
| 785 | match self.gate_row(&run.id, run.attempt as u32, &name).await? { | |
| 786 | Some(gate) => gate, | |
| 787 | None => return Ok(Gate::Held(held_reason(&name, needs_review, wait_until.as_deref()))), | |
| 788 | } | |
| 789 | } | |
| 790 | }; | |
| 791 | if gate.state == "rejected" { | |
| 792 | let by = gate.reviewed_by.as_deref().unwrap_or("A reviewer"); | |
| 793 | return Ok(Gate::Refused(format!("{by} rejected the deployment to {name}."))); | |
| 794 | } | |
| 795 | if gate_open(&gate.state, gate.needs_review != 0, gate.wait_until.as_deref(), &now()) { | |
| 796 | return Ok(Gate::Open); | |
| 797 | } | |
| 798 | let review_left = gate.needs_review != 0 && gate.state != "approved"; | |
| 799 | Ok(Gate::Held(held_reason(&name, review_left, gate.wait_until.as_deref()))) | |
| 800 | } | |
| 801 | ||
| 802 | async fn gate_row(&self, run_id: &str, attempt: u32, environment: &str) -> Result<Option<GateRow>> { | |
| 803 | self.db | |
| 804 | .prepare("SELECT * FROM environment_gates WHERE run_id = ? AND attempt = ? AND environment = ?") | |
| 805 | .bind(&[run_id.into(), attempt.into(), environment.into()])? | |
| 806 | .first::<GateRow>(None) | |
| 807 | .await | |
| 808 | } | |
| 809 | ||
| 810 | /// Whether the run's branch is protected: the default branch, or one an | |
| 811 | /// active rule holds. | |
| 812 | async fn branch_protected(&self, run: &RunRow) -> Result<bool> { | |
| 813 | let Some(branch) = run.git_ref.strip_prefix("refs/heads/") else { return Ok(false) }; | |
| 814 | let path = crate::repo_path(&run.repo); | |
| 815 | let Some(ws) = self.workspace_actor(&path.namespace).await? else { return Ok(false) }; | |
| 816 | let rules: Outcome<Value> = g1t_kit::call( | |
| 817 | &self.work, | |
| 818 | "effective_rules", | |
| 819 | &json!({ "viewer": ws, "repo": path, "name": branch, "target": "branch" }), | |
| 820 | ) | |
| 821 | .await | |
| 822 | .unwrap_or_else(|_| Outcome::Ok(Value::Null)); | |
| 823 | Ok(match rules { | |
| 824 | Outcome::Ok(rules) => { | |
| 825 | rules["default_branch"].as_bool() == Some(true) | |
| 826 | || rules["rules"].as_array().is_some_and(|rules| rules.iter().any(|rule| rule["enforcement"] == "active")) | |
| 827 | } | |
| 828 | Outcome::Fail(_) => false, | |
| 829 | }) | |
| 830 | } | |
| 831 | ||
| 832 | /// Tells an environment's reviewers that a run waits for them. | |
| 833 | async fn ask_reviewers(&self, run: &RunRow, env: &Environment) { | |
| 834 | let path = crate::repo_path(&run.repo); | |
| 835 | let mut people: Vec<String> = Vec::new(); | |
| 836 | for reviewer in &env.reviewers { | |
| 837 | let names = match reviewer.kind.as_str() { | |
| 838 | "team" => self.team_people(&path.namespace, &reviewer.name).await.ok().flatten().unwrap_or_default(), | |
| 839 | _ => vec![reviewer.name.clone()], | |
| 840 | }; | |
| 841 | for name in names { | |
| 842 | // Never whoever cannot approve their own run. | |
| 843 | let own = env.prevent_self_review && run.actor.as_deref().is_some_and(|by| by.eq_ignore_ascii_case(&name)); | |
| 844 | if !own && !people.contains(&name) { | |
| 845 | people.push(name); | |
| 846 | } | |
| 847 | } | |
| 848 | } | |
| 849 | if people.is_empty() { | |
| 850 | return; | |
| 851 | } | |
| 852 | let published: Result<()> = g1t_kit::call( | |
| 853 | &self.events, | |
| 854 | "publish", | |
| 855 | &g1t_contracts::events::Publish { | |
| 856 | events: vec![g1t_contracts::events::NewEvent { | |
| 857 | kind: "deployment.review_requested", | |
| 858 | source: "actions", | |
| 859 | repo_id: Some(run.repo_id.clone()), | |
| 860 | actor: run.actor_id.clone(), | |
| 861 | data: json!({ | |
| 862 | "repoId": run.repo_id, | |
| 863 | "runId": run.id, | |
| 864 | "attempt": run.attempt, | |
| 865 | "environment": env.name, | |
| 866 | "workflow": run.name, | |
| 867 | "title": run.title, | |
| 868 | "number": run.number, | |
| 869 | "notify": people, | |
| 870 | "link": format!("/{}/actions/runs/{}", run.repo, run.id), | |
| 871 | }), | |
| 872 | }], | |
| 873 | }, | |
| 874 | ) | |
| 875 | .await; | |
| 876 | if let Err(error) = published { | |
| 877 | worker::console_error!("actions: reviewers of {} not told for run {}: {error}", env.name, run.id); | |
| 878 | } | |
| 879 | } | |
| 880 | ||
| 881 | /// The environments holding a run's jobs this attempt, and whether | |
| 882 | /// `viewer` may decide on each. | |
| 883 | pub(crate) async fn pending_for(&self, run: &RunRow, viewer: &Viewer) -> Result<Vec<PendingDeployment>> { | |
| 884 | let gates = self | |
| 885 | .db | |
| 886 | .prepare("SELECT * FROM environment_gates WHERE run_id = ? AND attempt = ? ORDER BY environment") | |
| 887 | .bind(&[run.id.as_str().into(), (run.attempt as u32).into()])? | |
| 888 | .all() | |
| 889 | .await? | |
| 890 | .results::<GateRow>()?; | |
| 891 | if gates.is_empty() { | |
| 892 | return Ok(Vec::new()); | |
| 893 | } | |
| 894 | let jobs = self.job_rows(&run.id).await?; | |
| 895 | let repo = self.visible_repo(&crate::repo_path(&run.repo), viewer).await?; | |
| 896 | let mut out = Vec::new(); | |
| 897 | for gate in gates { | |
| 898 | let env = self.environment_rules(&run.repo_id, &gate.environment).await?; | |
| 899 | let held: Vec<&JobRow> = jobs.iter().filter(|job| job.environment.as_deref().is_some_and(|e| e.eq_ignore_ascii_case(&gate.environment))).collect(); | |
| 900 | let waiting = gate.state == "waiting" && held.iter().any(|job| job.status == "pending"); | |
| 901 | let can_review = match (viewer, &repo, waiting) { | |
| 902 | (Some(user), Some(repo), true) if user.kind == PrincipalKind::User && user.token.as_ref().is_none_or(|t| t.job.is_none()) => { | |
| 903 | let teams = self.reviewer_teams(&repo.namespace, &env, &user.username).await?; | |
| 904 | let admin = access::can(Some(user), repo, Capability::Administer); | |
| 905 | may_review(&env, &user.username, &teams, admin, run.actor.as_deref()).is_ok() | |
| 906 | } | |
| 907 | _ => false, | |
| 908 | }; | |
| 909 | out.push(PendingDeployment { | |
| 910 | environment: gate.environment.clone(), | |
| 911 | state: gate.state.clone(), | |
| 912 | needs_review: gate.needs_review != 0, | |
| 913 | wait_until: gate.wait_until.clone(), | |
| 914 | reviewers: env.reviewers.clone(), | |
| 915 | jobs: held.iter().map(|job| job.name.clone()).collect(), | |
| 916 | can_review, | |
| 917 | reviewed_by: gate.reviewed_by.clone(), | |
| 918 | comment: gate.comment.clone(), | |
| 919 | reviewed_at: gate.reviewed_at.clone(), | |
| 920 | }); | |
| 921 | } | |
| 922 | Ok(out) | |
| 923 | } | |
| 924 | ||
| 925 | pub async fn pending_deployments(&self, a: PendingDeploymentsArgs) -> Result<Outcome<Vec<PendingDeployment>>> { | |
| 926 | if self.visible_repo(&a.repo, &a.viewer).await?.is_none() { | |
| 927 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); | |
| 928 | } | |
| 929 | let run = check!(self.run_in(&a.repo, &a.id).await?); | |
| 930 | Ok(Outcome::Ok(self.pending_for(&run, &a.viewer).await?)) | |
| 931 | } | |
| 932 | ||
| 933 | /// `review_deployments`: a reviewer approves or rejects the jobs an | |
| 934 | /// environment holds in a run. | |
| 935 | pub async fn review_deployments(&self, a: ReviewDeploymentsArgs) -> Result<Outcome<Vec<PendingDeployment>>> { | |
| 936 | if let Some(refused) = refuse_job_token(&a.actor) { | |
| 937 | return Ok(refused); | |
| 938 | } | |
| 939 | if a.actor.kind != PrincipalKind::User { | |
| 940 | return Ok(fail(FailureCode::Forbidden, "Only a person can review a deployment.")); | |
| 941 | } | |
| 942 | let repo = check!(self.may(&a.actor, &a.repo, Capability::Read).await?); | |
| 943 | let approve = match a.state.trim() { | |
| 944 | "approved" | "approve" => true, | |
| 945 | "rejected" | "reject" => false, | |
| 946 | _ => return Ok(fail(FailureCode::Invalid, "state is approved or rejected.")), | |
| 947 | }; | |
| 948 | let run = check!(self.run_in(&a.repo, &a.id).await?); | |
| 949 | let wanted: Vec<String> = a.environments.iter().filter_map(|name| environment_name(name).ok()).collect(); | |
| 950 | let pending = self.pending_for(&run, &Some(a.actor.clone())).await?; | |
| 951 | let waiting: Vec<&PendingDeployment> = pending | |
| 952 | .iter() | |
| 953 | .filter(|p| p.state == "waiting" && !p.jobs.is_empty()) | |
| 954 | .filter(|p| wanted.is_empty() || wanted.contains(&p.environment)) | |
| 955 | .collect(); | |
| 956 | if waiting.is_empty() { | |
| 957 | return Ok(fail(FailureCode::Conflict, "No deployment of this run is waiting for review.")); | |
| 958 | } | |
| 959 | let comment: Option<String> = a.comment.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(|c| c.chars().take(1000).collect()); | |
| 960 | let admin = access::can(Some(&a.actor), &repo, Capability::Administer); | |
| 961 | let at = now(); | |
| 962 | for pending in &waiting { | |
| 963 | let env = self.environment_rules(&run.repo_id, &pending.environment).await?; | |
| 964 | let teams = self.reviewer_teams(&repo.namespace, &env, &a.actor.username).await?; | |
| 965 | let how = match may_review(&env, &a.actor.username, &teams, admin, run.actor.as_deref()) { | |
| 966 | Ok(how) => how, | |
| 967 | Err(problem) => return Ok(fail(FailureCode::Forbidden, problem)), | |
| 968 | }; | |
| 969 | let state = if approve { "approved" } else { "rejected" }; | |
| 970 | // An admin's approval past the rules skips the wait timer too. | |
| 971 | let clear_wait = approve && how == Reviewer::AdminBypass; | |
| 972 | self.db | |
| 973 | .prepare( | |
| 974 | "UPDATE environment_gates SET state = ?, reviewed_by = ?, comment = ?, reviewed_at = ?, | |
| 975 | wait_until = CASE WHEN ? THEN NULL ELSE wait_until END | |
| 976 | WHERE run_id = ? AND attempt = ? AND environment = ? AND state = 'waiting'", | |
| 977 | ) | |
| 978 | .bind(&[ | |
| 979 | state.into(), | |
| 980 | a.actor.username.as_str().into(), | |
| 981 | optional(comment.as_deref()), | |
| 982 | at.as_str().into(), | |
| 983 | u32::from(clear_wait).into(), | |
| 984 | run.id.as_str().into(), | |
| 985 | (run.attempt as u32).into(), | |
| 986 | pending.environment.as_str().into(), | |
| 987 | ])? | |
| 988 | .run() | |
| 989 | .await?; | |
| 990 | if !approve { | |
| 991 | let reason = match &comment { | |
| 992 | Some(comment) => format!("{} rejected the deployment to {}: {comment}", a.actor.username, pending.environment), | |
| 993 | None => format!("{} rejected the deployment to {}.", a.actor.username, pending.environment), | |
| 994 | }; | |
| 995 | self.db | |
| 996 | .prepare( | |
| 997 | "UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ? | |
| 998 | WHERE run_id = ? AND status = 'pending' AND lower(environment) = ?", | |
| 999 | ) | |
| 1000 | .bind(&[reason.into(), at.as_str().into(), run.id.as_str().into(), pending.environment.as_str().into()])? | |
| 1001 | .run() | |
| 1002 | .await?; | |
| 1003 | } | |
| 1004 | } | |
| 1005 | self.release_gates_of(&run).await?; | |
| 1006 | self.advance(&run.id).await?; | |
| 1007 | let run = self.run_row(&run.id).await?.unwrap_or(run); | |
| 1008 | Ok(Outcome::Ok(self.pending_for(&run, &Some(a.actor.clone())).await?)) | |
| 1009 | } | |
| 1010 | ||
| 1011 | /// Queues a run's pending jobs whose environments now let them start. | |
| 1012 | pub(crate) async fn release_gates_of(&self, run: &RunRow) -> Result<bool> { | |
| 1013 | let jobs = self.job_rows(&run.id).await?; | |
| 1014 | let at = now(); | |
| 1015 | let mut released = false; | |
| 1016 | for job in jobs.iter().filter(|job| job.status == "pending") { | |
| 1017 | let Some(environment) = job.environment.as_deref() else { continue }; | |
| 1018 | match self.gate(run, environment).await? { | |
| 1019 | Gate::Open => { | |
| 1020 | // A self-hosted job waits for a runner again. | |
| 1021 | let reason = job | |
| 1022 | .labels | |
| 1023 | .as_deref() | |
| 1024 | .and_then(|labels| serde_json::from_str::<Vec<String>>(labels).ok()) | |
| 1025 | .map(|stored| g1t_contracts::runners::waiting_reason(&g1t_contracts::runners::Wanted::from_stored(&stored))); | |
| 1026 | self.db | |
| 1027 | .prepare("UPDATE jobs SET status = 'queued', reason = ?, queued_at = ? WHERE id = ? AND status = 'pending'") | |
| 1028 | .bind(&[optional(reason.as_deref()), at.as_str().into(), job.id.as_str().into()])? | |
| 1029 | .run() | |
| 1030 | .await?; | |
| 1031 | released = true; | |
| 1032 | } | |
| 1033 | Gate::Held(reason) => { | |
| 1034 | if job.reason.as_deref() != Some(reason.as_str()) { | |
| 1035 | self.db | |
| 1036 | .prepare("UPDATE jobs SET reason = ? WHERE id = ? AND status = 'pending'") | |
| 1037 | .bind(&[reason.into(), job.id.as_str().into()])? | |
| 1038 | .run() | |
| 1039 | .await?; | |
| 1040 | } | |
| 1041 | } | |
| 1042 | Gate::Refused(reason) => { | |
| 1043 | self.db | |
| 1044 | .prepare("UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ? WHERE id = ? AND status = 'pending'") | |
| 1045 | .bind(&[reason.into(), at.as_str().into(), job.id.as_str().into()])? | |
| 1046 | .run() | |
| 1047 | .await?; | |
| 1048 | released = true; | |
| 1049 | } | |
| 1050 | } | |
| 1051 | } | |
| 1052 | Ok(released) | |
| 1053 | } | |
| 1054 | ||
| 1055 | /// Every minute: runs whose jobs wait at an environment whose timer has | |
| 1056 | /// run out, or whose review came in, move along. | |
| 1057 | pub(crate) async fn release_gates(&self) -> Result<()> { | |
| 1058 | #[derive(Deserialize)] | |
| 1059 | struct Waiting { | |
| 1060 | run_id: String, | |
| 1061 | } | |
| 1062 | let runs = self | |
| 1063 | .db | |
| 1064 | .prepare("SELECT DISTINCT run_id FROM jobs WHERE status = 'pending' LIMIT 100") | |
| 1065 | .all() | |
| 1066 | .await? | |
| 1067 | .results::<Waiting>()?; | |
| 1068 | for waiting in runs { | |
| 1069 | let Some(run) = self.run_row(&waiting.run_id).await? else { continue }; | |
| 1070 | if self.release_gates_of(&run).await? { | |
| 1071 | self.advance(&run.id).await?; | |
| 1072 | } | |
| 1073 | } | |
| 1074 | Ok(()) | |
| 1075 | } | |
| 1076 | ||
| 1077 | // --- Approving runs of pull requests from outside -------------------------- | |
| 1078 | ||
| 1079 | /// Why a pull request's run waits for approval, if it does: by the | |
| 1080 | /// repository's policy, whoever the pull request is for (its owner). | |
| 1081 | pub(crate) async fn approval_needed(&self, repo: &Repo, owner: &User, ws: &User) -> Result<Option<String>> { | |
| 1082 | // g1t's own work, nobody asked for, is the workspace's. | |
| 1083 | if owner.id == AGENT_ID || owner.kind == PrincipalKind::System { | |
| 1084 | return Ok(None); | |
| 1085 | } | |
| 1086 | let policy = self.approval_policy(&repo.id).await?; | |
| 1087 | let permission: Outcome<PermissionInfo> = g1t_kit::call( | |
| 1088 | &self.identity, | |
| 1089 | "collaborator_permission", | |
| 1090 | &CollaboratorPermissionArgs { | |
| 1091 | viewer: Some(ws.clone()), | |
| 1092 | path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, | |
| 1093 | username: owner.username.clone(), | |
| 1094 | }, | |
| 1095 | ) | |
| 1096 | .await?; | |
| 1097 | let info = permission.into_result().ok(); | |
| 1098 | let member = owner.is_member(&repo.namespace.to_lowercase()) | |
| 1099 | || info.as_ref().is_some_and(|info| matches!(info.source, Some(AccessSource::Owner | AccessSource::Base | AccessSource::Team))); | |
| 1100 | if member { | |
| 1101 | return Ok(None); | |
| 1102 | } | |
| 1103 | let can_push = info.as_ref().and_then(|info| info.role).is_some_and(|role| access::allows(role, Capability::Push)); | |
| 1104 | let first_time = !self.has_merged(repo, owner, ws).await?; | |
| 1105 | Ok(approval_reason(&policy, member, can_push, first_time).map(|why| format!("{} {why}, so someone with the Write role approves its runs first.", owner.username))) | |
| 1106 | } | |
| 1107 | ||
| 1108 | /// Whether a pull request of `owner`'s has been merged into `repo`. | |
| 1109 | async fn has_merged(&self, repo: &Repo, owner: &User, ws: &User) -> Result<bool> { | |
| 1110 | let closed: Outcome<Vec<g1t_contracts::work::Pull>> = g1t_kit::call( | |
| 1111 | &self.work, | |
| 1112 | "list_pulls", | |
| 1113 | &g1t_contracts::work::ListPullsArgs { | |
| 1114 | repo: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, | |
| 1115 | viewer: Some(ws.clone()), | |
| 1116 | state: Some(g1t_contracts::work::State::Closed), | |
| 1117 | label: None, | |
| 1118 | milestone: None, | |
| 1119 | base: None, | |
| 1120 | }, | |
| 1121 | ) | |
| 1122 | .await | |
| 1123 | .unwrap_or_else(|_| Outcome::Ok(Vec::new())); | |
| 1124 | Ok(closed.into_result().unwrap_or_default().iter().any(|pull| pull.merged_at.is_some() && pull.owner().id == owner.id)) | |
| 1125 | } | |
| 1126 | ||
| 1127 | /// `approve_run`: someone with the Write role lets a pull request's run | |
| 1128 | /// that waits for approval start. | |
| 1129 | pub async fn approve_run(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> { | |
| 1130 | if let Some(refused) = refuse_job_token(&a.actor) { | |
| 1131 | return Ok(refused); | |
| 1132 | } | |
| 1133 | check!(self.may(&a.actor, &a.repo, Capability::Run).await?); | |
| 1134 | let run = check!(self.run_in(&a.repo, &a.id).await?); | |
| 1135 | if run.status != "action_required" { | |
| 1136 | return Ok(fail(FailureCode::Conflict, "This run is not waiting for approval.")); | |
| 1137 | } | |
| 1138 | let mut approval = run.approval().unwrap_or(RunApproval { state: "required".into(), reason: String::new(), approved_by: None }); | |
| 1139 | approval.state = "approved".to_owned(); | |
| 1140 | approval.approved_by = Some(a.actor.username.clone()); | |
| 1141 | let started = self | |
| 1142 | .db | |
| 1143 | .prepare("UPDATE runs SET status = 'queued', approval = ?, approved_by = ? WHERE id = ? AND status = 'action_required' RETURNING id") | |
| 1144 | .bind(&[serde_json::to_string(&approval)?.into(), a.actor.username.as_str().into(), run.id.as_str().into()])? | |
| 1145 | .first::<Value>(None) | |
| 1146 | .await?; | |
| 1147 | if started.is_none() { | |
| 1148 | return Ok(fail(FailureCode::Conflict, "This run is not waiting for approval.")); | |
| 1149 | } | |
| 1150 | if let Some(run) = self.run_row(&run.id).await? { | |
| 1151 | self.enter_group(&run).await?; | |
| 1152 | } | |
| 1153 | self.run_summary(&run.id).await | |
| 1154 | } | |
| 1155 | } | |
| 1156 | ||
| 1157 | /// A workflow job's own token may not approve or change what keeps runs | |
| 1158 | /// safe: a workflow could otherwise let itself through. | |
| 1159 | fn refuse_job_token<T>(actor: &User) -> Option<Outcome<T>> { | |
| 1160 | actor | |
| 1161 | .token | |
| 1162 | .as_ref() | |
| 1163 | .is_some_and(|token| token.job.is_some()) | |
| 1164 | .then(|| fail(FailureCode::Forbidden, "A workflow job's token cannot approve runs or deployments, or change their rules.")) | |
| 1165 | } | |
| 1166 | ||
| 1167 | #[cfg(test)] | |
| 1168 | mod tests { | |
| 1169 | use super::*; | |
| 1170 | ||
| 1171 | fn env(reviewers: &[(&str, &str)]) -> Environment { | |
| 1172 | Environment { | |
| 1173 | reviewers: reviewers.iter().map(|(kind, name)| EnvironmentReviewer { kind: (*kind).into(), name: (*name).into() }).collect(), | |
| 1174 | ..unprotected("production") | |
| 1175 | } | |
| 1176 | } | |
| 1177 | ||
| 1178 | fn pattern(name: &str, kind: &str) -> BranchPattern { | |
| 1179 | BranchPattern { name: name.into(), kind: kind.into() } | |
| 1180 | } | |
| 1181 | ||
| 1182 | #[test] | |
| 1183 | fn the_default_token_the_github_way() { | |
| 1184 | use TokenDefault::{Permissive, Restricted}; | |
| 1185 | // A new repository takes its workspace's default: read-only unless it says. | |
| 1186 | assert_eq!(effective_default(None, false, Restricted, Permissive), Restricted); | |
| 1187 | assert_eq!(effective_default(None, false, Permissive, Permissive), Permissive); | |
| 1188 | // One made before restricted tokens keeps read and write. | |
| 1189 | assert_eq!(effective_default(None, true, Restricted, Permissive), Permissive); | |
| 1190 | // A repository's own choice wins... | |
| 1191 | assert_eq!(effective_default(Some(Restricted), true, Permissive, Permissive), Restricted); | |
| 1192 | assert_eq!(effective_default(Some(Permissive), false, Restricted, Permissive), Permissive); | |
| 1193 | // ...but never past the workspace's maximum. | |
| 1194 | assert_eq!(effective_default(Some(Permissive), true, Permissive, Restricted), Restricted); | |
| 1195 | assert_eq!(effective_default(None, true, Restricted, Restricted), Restricted); | |
| 1196 | // A workspace without a row: new repositories read-only, writes allowed, no pull requests. | |
| 1197 | let policy = workspace_policy(None); | |
| 1198 | assert_eq!((policy.default_permissions.as_str(), policy.max_permissions.as_str()), ("read", "write")); | |
| 1199 | assert!(!policy.can_approve_pull_requests); | |
| 1200 | } | |
| 1201 | ||
| 1202 | #[test] | |
| 1203 | fn which_refs_may_deploy() { | |
| 1204 | assert!(branch_allowed("all", &[], "refs/pull/3/merge", false, "production").is_ok()); | |
| 1205 | assert!(branch_allowed("protected", &[], "refs/heads/main", true, "production").is_ok()); | |
| 1206 | assert!(branch_allowed("protected", &[], "refs/heads/feature", false, "production").unwrap_err().contains("protected branches only")); | |
| 1207 | // A pull request's merge ref is never a protected branch. | |
| 1208 | assert!(branch_allowed("protected", &[], "refs/pull/3/merge", true, "production").is_err()); | |
| 1209 | let selected = [pattern("main", "branch"), pattern("release/*", "branch"), pattern("v*", "tag")]; | |
| 1210 | assert!(branch_allowed("selected", &selected, "refs/heads/release/1.x", false, "production").is_ok()); | |
| 1211 | assert!(branch_allowed("selected", &selected, "refs/tags/v1.2.0", false, "production").is_ok()); | |
| 1212 | assert!(branch_allowed("selected", &selected, "refs/heads/feature", false, "production").is_err()); | |
| 1213 | // A tag pattern does not let a branch of that name through. | |
| 1214 | assert!(branch_allowed("selected", &[pattern("v*", "tag")], "refs/heads/v2", false, "production").is_err()); | |
| 1215 | assert!(branch_allowed("selected", &[], "refs/heads/main", false, "production").is_err()); | |
| 1216 | } | |
| 1217 | ||
| 1218 | #[test] | |
| 1219 | fn who_may_review() { | |
| 1220 | let production = env(&[("user", "ada"), ("team", "deployers")]); | |
| 1221 | assert_eq!(may_review(&production, "ada", &[], false, Some("bo")), Ok(Reviewer::Listed)); | |
| 1222 | assert_eq!(may_review(&production, "Ada", &[], false, None), Ok(Reviewer::Listed), "names compare without case"); | |
| 1223 | assert_eq!(may_review(&production, "cy", &["deployers".into()], false, None), Ok(Reviewer::Listed)); | |
| 1224 | assert!(may_review(&production, "cy", &[], false, None).unwrap_err().contains("not one of")); | |
| 1225 | // An admin past the rules, unless the environment says no. | |
| 1226 | assert_eq!(may_review(&production, "cy", &[], true, None), Ok(Reviewer::AdminBypass)); | |
| 1227 | let strict = Environment { admins_bypass: false, ..production.clone() }; | |
| 1228 | assert!(may_review(&strict, "cy", &[], true, None).is_err()); | |
| 1229 | // Whoever started the run, when self-review is off. | |
| 1230 | let no_self = Environment { prevent_self_review: true, ..production.clone() }; | |
| 1231 | assert!(may_review(&no_self, "ada", &[], false, Some("ada")).unwrap_err().contains("You started this run")); | |
| 1232 | assert_eq!(may_review(&no_self, "ada", &[], false, Some("bo")), Ok(Reviewer::Listed)); | |
| 1233 | assert_eq!(may_review(&no_self, "ada", &[], true, Some("ada")), Ok(Reviewer::AdminBypass)); | |
| 1234 | // Without reviewers there is nothing to review; an admin may start it. | |
| 1235 | let timer = Environment { wait_minutes: 30, ..env(&[]) }; | |
| 1236 | assert!(may_review(&timer, "ada", &[], false, None).unwrap_err().contains("no reviewers")); | |
| 1237 | assert_eq!(may_review(&timer, "ada", &[], true, None), Ok(Reviewer::AdminBypass)); | |
| 1238 | } | |
| 1239 | ||
| 1240 | #[test] | |
| 1241 | fn a_gate_opens_on_review_and_time() { | |
| 1242 | let now = "2026-10-08T12:00:00Z"; | |
| 1243 | assert!(!gate_open("waiting", true, None, now)); | |
| 1244 | assert!(gate_open("approved", true, None, now)); | |
| 1245 | assert!(!gate_open("approved", true, Some("2026-10-08T12:30:00Z"), now)); | |
| 1246 | assert!(gate_open("approved", true, Some("2026-10-08T11:30:00Z"), now)); | |
| 1247 | assert!(gate_open("waiting", false, Some("2026-10-08T11:59:00Z"), now)); | |
| 1248 | assert!(!gate_open("waiting", false, Some("2026-10-08T12:01:00Z"), now)); | |
| 1249 | assert!(!gate_open("rejected", false, None, now)); | |
| 1250 | assert_eq!(held_reason("production", true, None), "Waiting for a review to deploy to production."); | |
| 1251 | assert!(held_reason("staging", false, Some("2026-10-08T12:30:00Z")).contains("wait timer")); | |
| 1252 | } | |
| 1253 | ||
| 1254 | #[test] | |
| 1255 | fn which_pull_requests_wait_for_approval() { | |
| 1256 | // Members never wait. | |
| 1257 | for policy in APPROVAL_POLICIES { | |
| 1258 | assert_eq!(approval_reason(policy, true, true, true), None); | |
| 1259 | } | |
| 1260 | // The default: anyone who cannot push, and an outside collaborator's first. | |
| 1261 | assert!(approval_reason("outside_contributors", false, false, false).is_some()); | |
| 1262 | assert!(approval_reason("outside_contributors", false, true, true).is_some()); | |
| 1263 | assert_eq!(approval_reason("outside_contributors", false, true, false), None); | |
| 1264 | // First-time contributors only. | |
| 1265 | assert!(approval_reason("first_time_contributors", false, false, true).is_some()); | |
| 1266 | assert_eq!(approval_reason("first_time_contributors", false, false, false), None); | |
| 1267 | // Everyone outside the workspace. | |
| 1268 | assert!(approval_reason("all_external_contributors", false, true, false).is_some()); | |
| 1269 | } | |
| 1270 | ||
| 1271 | #[test] | |
| 1272 | fn a_jobs_permissions_are_g1t_scopes_and_outside_runs_read_only() { | |
| 1273 | use g1t_actions::permissions::{Access, Permissions}; | |
| 1274 | use g1t_contracts::scopes::{Level, Scope, TokenAccess}; | |
| 1275 | for scope in Permissions::all(Access::Write).scopes() { | |
| 1276 | assert!(Scope::parse(scope).is_some(), "{scope} is not a g1t scope"); | |
| 1277 | } | |
| 1278 | let token = |permissions: &Permissions| TokenAccess { | |
| 1279 | scopes: Some(permissions.scopes().into_iter().map(str::to_owned).collect()), | |
| 1280 | repo: Some("acme/web".into()), | |
| 1281 | ..TokenAccess::default() | |
| 1282 | }; | |
| 1283 | // The default: it can clone, and nothing more. | |
| 1284 | let restricted = token(&Permissions::default_for(TokenDefault::Restricted)); | |
| 1285 | assert!(restricted.allows(Scope::CodeRead)); | |
| 1286 | assert!(!restricted.allows(Scope::CodeWrite)); | |
| 1287 | assert!(!restricted.allows(Scope::IssuesWrite)); | |
| 1288 | // A pull request from outside reads, whatever it asks for. | |
| 1289 | let outside = token(&Permissions::all(Access::Write).read_only()); | |
| 1290 | assert!(Scope::ALL.iter().filter(|scope| outside.allows(**scope)).all(|scope| scope.level() == Level::Read)); | |
| 1291 | // Never more than a repository's: no admin, no secrets, no agents. | |
| 1292 | let everything = token(&Permissions::all(Access::Write)); | |
| 1293 | for never in [Scope::RepoAdmin, Scope::SecretsRead, Scope::AgentsRun, Scope::WorkspaceRead, Scope::RunnersRead] { | |
| 1294 | assert!(!everything.allows(never), "{}", never.as_str()); | |
| 1295 | } | |
| 1296 | } | |
| 1297 | ||
| 1298 | #[test] | |
| 1299 | fn environment_names_are_checked() { | |
| 1300 | assert_eq!(environment_name(" Production ").unwrap(), "production"); | |
| 1301 | assert!(environment_name("staging env").is_err()); | |
| 1302 | assert!(environment_name("").is_err()); | |
| 1303 | assert!(environment_name(&"a".repeat(41)).is_err()); | |
| 1304 | } | |
| 1305 | ||
| 1306 | #[test] | |
| 1307 | fn a_job_token_cannot_let_itself_through() { | |
| 1308 | let mut actor = User { id: "wsp_1".into(), username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() }; | |
| 1309 | assert!(refuse_job_token::<()>(&actor).is_none()); | |
| 1310 | actor.token = Some(Box::new(g1t_contracts::scopes::TokenAccess { | |
| 1311 | job: Some(g1t_contracts::scopes::JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }), | |
| 1312 | ..Default::default() | |
| 1313 | })); | |
| 1314 | assert!(refuse_job_token::<()>(&actor).is_some()); | |
| 1315 | } | |
| 1316 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.