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

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-a3abfcce648e87dca'1//! Keeping runs safe: a repository's choices for its workflows (what a
2//! job's token may do when its workflow says nothing, and which pull
3//! requests' runs wait for approval), environments' protection rules, the
4//! reviews of the jobs they hold, and approving a pull request's run.
5//!
6//! - A job that names an environment with rules is `pending` once its needs
7//! are done: until a reviewer approves it (when the environment has
8//! reviewers), its wait timer has run out (when it has one), and only on
9//! a ref the environment lets deploy. Its secrets are read when it
10//! starts, so the environment's are never handed out before then. One
11//! review approves every job of the run attempt that names the
12//! environment.
13//! - A run of a pull request from outside the workspace may wait as
14//! `action_required` until someone with the Write role approves it, by
15//! the repository's approval policy.
16
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>,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts50 /// Migration 0008: who may use its actions and reusable workflows.
51 #[serde(default)]
52 access_level: Option<String>,
Merge branch 'worktree-agent-a3abfcce648e87dca'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
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts264 .prepare("SELECT default_permissions, approval_policy, can_approve_pulls, access_level FROM repo_settings WHERE repo_id = ?")
Merge branch 'worktree-agent-a3abfcce648e87dca'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,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts315 access_level: row.access_level.as_deref().and_then(g1t_contracts::actions::access_level).unwrap_or("none").to_owned(),
Merge branch 'worktree-agent-a3abfcce648e87dca'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
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts334 /// 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
Merge branch 'worktree-agent-a3abfcce648e87dca'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 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts385 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 }
Merge branch 'worktree-agent-a3abfcce648e87dca'391 // 0006's artifact retention is kept, or its default for a new row.
392 self.db
393 .prepare(
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts394 "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)
Merge branch 'worktree-agent-a3abfcce648e87dca'396 ON CONFLICT (repo_id) DO UPDATE SET default_permissions = ?2, approval_policy = ?3, can_approve_pulls = ?4,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts397 access_level = ?8, updated_at = ?5, updated_by = ?6",
Merge branch 'worktree-agent-a3abfcce648e87dca'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(),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts407 optional(row.access_level.as_deref()),
Merge branch 'worktree-agent-a3abfcce648e87dca'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 }
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9500 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>> {
Merge branch 'worktree-agent-a3abfcce648e87dca'506 let rows = self
507 .db
508 .prepare("SELECT * FROM environments WHERE repo_id = ? ORDER BY name")
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9509 .bind(&[repo_id.into()])?
Merge branch 'worktree-agent-a3abfcce648e87dca'510 .all()
511 .await?
512 .results::<EnvRow>()?;
513 let mut out: Vec<Environment> = rows.iter().map(EnvRow::view).collect();
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9514 let mut named: Vec<String> = self.secret_environments(repo_id).await?;
Merge branch 'worktree-agent-a3abfcce648e87dca'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")
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9522 .bind(&[repo_id.into()])?
Merge branch 'worktree-agent-a3abfcce648e87dca'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")
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9534 .bind(&[repo_id.into()])?
Merge branch 'worktree-agent-a3abfcce648e87dca'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)));
Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9559 Ok(out)
Merge branch 'worktree-agent-a3abfcce648e87dca'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.
The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were.1082 if owner.kind == PrincipalKind::System {
Merge branch 'worktree-agent-a3abfcce648e87dca'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}

This file's history is long; its oldest lines are credited to the oldest commit read.