Skip to content
1,316 linesCodeBlameRaw
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
17use g1t_actions::filter::Pattern;
18use g1t_actions::permissions::TokenDefault;
19use g1t_contracts::access::{self, AccessSource, Capability, CollaboratorPermissionArgs, PermissionInfo};
20use 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};
26use g1t_contracts::identity::AGENT_ID;
27use g1t_contracts::repos::{Repo, RepoPath};
28use g1t_contracts::time::rfc3339;
29use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
30use g1t_kit::now_ms;
31use serde::Deserialize;
32use serde_json::{Value, json};
33use worker::Result;
34
35use crate::plan::{JobRow, RunRow};
36use crate::{Actions, check, fail, optional};
37
38/// What the approval policy is when a repository chose none.
39pub const DEFAULT_APPROVAL_POLICY: &str = "outside_contributors";
40
41fn 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)]
47struct SettingsRow {
48 default_permissions: Option<String>,
49 approval_policy: Option<String>,
50 can_approve_pulls: Option<u32>,
51 /// Migration 0008: who may use its actions and reusable workflows.
52 #[serde(default)]
53 access_level: Option<String>,
54}
55
56#[derive(Deserialize)]
57struct 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.
67pub(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.
73fn 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)]
89struct 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
101impl 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.
119fn 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)]
135struct 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 `_`.
147pub(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`.
157pub(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)]
183pub(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.
192pub(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`.
220pub(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).
227pub(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.
240fn 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.
250pub(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
259impl 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
265 .prepare("SELECT default_permissions, approval_policy, can_approve_pulls, access_level FROM repo_settings WHERE repo_id = ?")
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,
316 access_level: row.access_level.as_deref().and_then(g1t_contracts::actions::access_level).unwrap_or("none").to_owned(),
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
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
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 }
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 }
392 // 0006's artifact retention is kept, or its default for a new row.
393 self.db
394 .prepare(
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)
397 ON CONFLICT (repo_id) DO UPDATE SET default_permissions = ?2, approval_policy = ?3, can_approve_pulls = ?4,
398 access_level = ?8, updated_at = ?5, updated_by = ?6",
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(),
408 optional(row.access_level.as_deref()),
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 }
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>> {
507 let rows = self
508 .db
509 .prepare("SELECT * FROM environments WHERE repo_id = ? ORDER BY name")
510 .bind(&[repo_id.into()])?
511 .all()
512 .await?
513 .results::<EnvRow>()?;
514 let mut out: Vec<Environment> = rows.iter().map(EnvRow::view).collect();
515 let mut named: Vec<String> = self.secret_environments(repo_id).await?;
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")
523 .bind(&[repo_id.into()])?
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")
535 .bind(&[repo_id.into()])?
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)));
560 Ok(out)
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.
1159fn 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)]
1168mod 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}