Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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