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

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