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