Skip to content
568 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.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! Secrets and variables, a repository's or its workspace's: one list for
2//! every reader, shaped like Vercel's environment variables. Each row is a
3//! key, its type (a secret, or a variable shown as Config), the
4//! environments it applies to and who reads it: workflows, deployments, or
5//! both. A key may have one row per environment, so production and
6//! previews can hold different values; a key's rows never overlap.
7//!
8//! A reader asking for an environment gets the row naming it, else the
9//! key's row for every environment. A project's row overrides its
10//! workspace's of the same key.
11//!
12//! A repository's rows belong to its project (its primary one, when it
13//! carries several): asked for by repository, as GitHub's API does, they
14//! are the project's. Rows from before projects move over the first time
15//! they are touched. Names are upper-cased, as GitHub treats
16//! them without regard to case. Agents never read any.
17
18use g1t_contracts::access::Capability;
19use g1t_contracts::actions::{
20 CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs,
21 SettingsOwner,
22};
23use g1t_contracts::projects::{ByRepoArgs, ProjectRef};
24use g1t_contracts::time::rfc3339;
25use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
26use g1t_kit::now_ms;
27use serde::Deserialize;
28use serde_json::{Map, Value};
29use worker::Result;
30use worker::wasm_bindgen::JsValue;
31
32use crate::{Actions, check, fail};
33
34/// The largest value, as on GitHub.
35const MAX_VALUE_BYTES: usize = 48 * 1024;
36const MAX_PER_OWNER: u32 = 200;
37const MAX_NOTE: usize = 500;
38
39#[derive(Deserialize)]
40struct SettingRow {
41 id: String,
42 scope: String,
43 kind: String,
44 name: String,
45 value: String,
46 updated_at: String,
47 available_to: String,
48 environments: String,
49 repositories: Option<String>,
50 note: Option<String>,
51 updated_by: Option<String>,
52}
53
54/// A name GitHub would accept: letters, digits and `_`, not starting with
55/// a digit, `GITHUB_` or `G1T_`, which are g1t's own (`G1T_TOKEN` and its
56/// alias `GITHUB_TOKEN`).
57fn valid_name(name: &str) -> Result<String, String> {
58 let upper = name.trim().to_ascii_uppercase();
59 if upper.is_empty() || upper.len() > 100 {
60 return Err("A name is 1 to 100 characters.".to_owned());
61 }
62 if !upper.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
63 return Err("A name has only letters, digits and underscores.".to_owned());
64 }
65 if upper.starts_with(|c: char| c.is_ascii_digit()) {
66 return Err("A name cannot start with a digit.".to_owned());
67 }
68 if upper.starts_with("GITHUB_") || upper.starts_with("G1T_") {
69 return Err("Names starting with G1T_ or GITHUB_ are kept for g1t's own, such as G1T_TOKEN.".to_owned());
70 }
71 Ok(upper)
72}
73
74/// Environments' names: lowercase letters, digits, `-` and `_`, each once.
75fn valid_environments(list: &[String]) -> Result<Vec<String>, String> {
76 let mut out: Vec<String> = Vec::new();
77 for name in list {
78 let lower = name.trim().to_ascii_lowercase();
79 if lower.is_empty() {
80 continue;
81 }
82 if lower.len() > 40 || !lower.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
83 return Err(format!("`{name}` is not an environment's name: up to 40 letters, digits, - and _."));
84 }
85 if !out.contains(&lower) {
86 out.push(lower);
87 }
88 }
89 out.sort();
90 Ok(out)
91}
92
93fn consumers(list: &[String]) -> Result<Vec<String>, String> {
94 let mut out: Vec<String> = Vec::new();
95 for item in list {
96 let item = item.trim().to_ascii_lowercase();
97 if !CONSUMERS.contains(&item.as_str()) {
98 return Err(format!("`{item}` is not a reader: use workflows or deployments."));
99 }
100 if !out.contains(&item) {
101 out.push(item);
102 }
103 }
104 if out.is_empty() {
105 return Err("Choose who reads it: workflows, deployments, or both.".to_owned());
106 }
107 Ok(out)
108}
109
110fn split(list: &str) -> Vec<String> {
111 list.split(',').filter(|s| !s.is_empty()).map(str::to_owned).collect()
112}
113
114impl SettingRow {
115 fn environments(&self) -> Vec<String> {
116 split(&self.environments)
117 }
118
119 /// A workspace's row: the projects it reaches. The column predates
120 /// projects; it holds their slugs.
121 fn projects(&self) -> Vec<String> {
122 self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default()
123 }
124
125 fn reaches(&self, project: &str) -> bool {
126 let list = self.projects();
127 list.is_empty() || list.iter().any(|p| p.eq_ignore_ascii_case(project))
128 }
129
130 /// Whether it and rows for `environments` would both apply somewhere.
131 fn overlaps(&self, environments: &[String]) -> bool {
132 let mine = self.environments();
133 mine.is_empty() == environments.is_empty() && (mine.is_empty() || mine.iter().any(|e| environments.contains(e)))
134 }
135
136 fn describe(self) -> Setting {
137 Setting {
138 available_to: split(&self.available_to),
139 environments: self.environments(),
140 projects: self.projects(),
141 value: (self.kind == "variable").then_some(self.value),
142 id: self.id,
143 name: self.name,
144 kind: self.kind,
145 scope: self.scope,
146 updated_at: self.updated_at,
147 note: self.note,
148 updated_by: self.updated_by,
149 }
150 }
151}
152
153/// Where settings live: `(scope, owner)` with the owner a project id or a
154/// workspace slug.
155struct Place {
156 scope: &'static str,
157 owner: String,
158 namespace: String,
159 /// The project's slug, for a project's place.
160 slug: String,
161}
162
163impl Actions {
164 async fn place(&self, actor: &User, owner: &SettingsOwner, changing: bool) -> Result<Outcome<Place>> {
165 if actor.kind == PrincipalKind::Agent {
166 return Ok(fail(FailureCode::Forbidden, "Agents cannot read or change secrets and variables."));
167 }
168 // A workspace's tokens, G1T_TOKEN among them, read the names but
169 // never change them: a workflow must not rewrite what it runs with.
170 if changing && actor.kind == PrincipalKind::Workspace {
171 return Ok(fail(
172 FailureCode::Forbidden,
173 "A workspace's tokens, G1T_TOKEN included, cannot change secrets and variables. Use a person's token or the site.",
174 ));
175 }
176 match (&owner.repo, &owner.workspace) {
177 (Some(path), _) => {
178 // A repository's secrets and variables, seen or changed,
179 // go with its webhooks and deployments: the Admin role.
180 let repo = match self.may(actor, path, Capability::ManageIntegrations).await? {
181 Outcome::Ok(repo) => repo,
182 Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)),
183 };
184 let Some(project) = self.project_of(&repo.id).await? else {
185 return Ok(fail(FailureCode::NotFound, "The repository has no project."));
186 };
187 Ok(Outcome::Ok(Place { scope: "project", owner: project.id, namespace: repo.namespace, slug: project.slug }))
188 }
189 (None, Some(slug)) => {
190 let slug = slug.to_lowercase();
191 let role = actor.workspaces.iter().find(|m| m.slug.eq_ignore_ascii_case(&slug)).map(|m| m.role);
192 match role {
193 None => Ok(fail(FailureCode::Forbidden, format!("Only members of {slug} can see its secrets and variables."))),
194 Some(Role::Member) if changing => Ok(fail(FailureCode::Forbidden, format!("Only owners of {slug} can change its secrets and variables."))),
195 Some(_) => Ok(Outcome::Ok(Place { scope: "workspace", owner: slug.clone(), namespace: slug, slug: String::new() })),
196 }
197 }
198 (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")),
199 }
200 }
201
202 /// A repository's primary project, with the rows kept under the
203 /// repository before projects moved to it.
204 pub(crate) async fn project_of(&self, repo_id: &str) -> Result<Option<ProjectRef>> {
205 let projects: Vec<ProjectRef> =
206 g1t_kit::call(&self.projects, "by_repo", &ByRepoArgs { repo_id: repo_id.to_owned() }).await?;
207 let Some(project) = projects.into_iter().find(|p| p.primary) else {
208 return Ok(None);
209 };
210 self.db
211 .prepare("UPDATE settings SET owner = ?, scope = 'project' WHERE owner = ?")
212 .bind(&[project.id.as_str().into(), repo_id.into()])?
213 .run()
214 .await?;
215 Ok(Some(project))
216 }
217
Merge branch 'worktree-agent-a3abfcce648e87dca'218 /// The environments a repository's own secrets and variables name.
219 pub(crate) async fn secret_environments(&self, repo_id: &str) -> Result<Vec<String>> {
220 let Some(project) = self.project_of(repo_id).await? else {
221 return Ok(Vec::new());
222 };
223 let mut out: Vec<String> = Vec::new();
224 for row in self.rows(&project.id).await? {
225 for environment in row.environments() {
226 if !out.contains(&environment) {
227 out.push(environment);
228 }
229 }
230 }
231 Ok(out)
232 }
233
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor234 /// `secret`, `variable`, or `None` for both.
235 fn kind(kind: &str) -> Outcome<Option<&'static str>> {
236 match kind {
237 "secret" | "secrets" => Outcome::Ok(Some("secret")),
238 "variable" | "variables" | "config" => Outcome::Ok(Some("variable")),
239 "" | "all" => Outcome::Ok(None),
240 _ => fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."),
241 }
242 }
243
244 async fn rows(&self, owner: &str) -> Result<Vec<SettingRow>> {
245 self.db
246 .prepare("SELECT * FROM settings WHERE owner = ? ORDER BY name, environments")
247 .bind(&[owner.into()])?
248 .all()
249 .await?
250 .results::<SettingRow>()
251 }
252
253 pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> {
254 let kind = check!(Self::kind(&a.kind));
255 let place = check!(self.place(&a.actor, &a.owner, false).await?);
256 let mut out: Vec<Setting> = Vec::new();
257 // A project's list shows the workspace's rows that reach it, but for
258 // keys it sets itself.
259 if place.scope == "project" {
260 let own: Vec<String> = self.rows(&place.owner).await?.into_iter().map(|row| row.name).collect();
261 for row in self.rows(&place.namespace.to_lowercase()).await? {
262 if row.reaches(&place.slug) && !own.contains(&row.name) {
263 out.push(row.describe());
264 }
265 }
266 }
267 out.extend(self.rows(&place.owner).await?.into_iter().map(SettingRow::describe));
268 out.retain(|setting| kind.is_none_or(|kind| setting.kind == kind));
269 out.sort_by(|a, b| a.name.cmp(&b.name).then(a.environments.cmp(&b.environments)));
270 Ok(Outcome::Ok(out))
271 }
272
273 fn seal(&self, value: &str, id: &str) -> Outcome<String> {
274 match &self.sealer {
275 Some(sealer) => Outcome::Ok(sealer.seal(value, id)),
276 None => fail(FailureCode::Conflict, "Secrets cannot be saved yet: g1t's key for them is not set."),
277 }
278 }
279
280 pub async fn set_setting(&self, a: SetSettingArgs) -> Result<Outcome<Setting>> {
281 let Some(kind) = check!(Self::kind(&a.kind)) else {
282 return Ok(fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."));
283 };
284 let name = match valid_name(&a.name) {
285 Ok(name) => name,
286 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
287 };
288 if a.value.as_ref().is_some_and(|v| v.len() > MAX_VALUE_BYTES) {
289 return Ok(fail(FailureCode::Invalid, "A value is at most 48 KB."));
290 }
291 if a.note.as_ref().is_some_and(|n| n.len() > MAX_NOTE) {
292 return Ok(fail(FailureCode::Invalid, "A note is at most 500 characters."));
293 }
294 let readers = match a.available_to.as_deref().map(consumers).transpose() {
295 Ok(readers) => readers,
296 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
297 };
298 let environments = match a.environments.as_deref().map(valid_environments).transpose() {
299 Ok(environments) => environments,
300 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
301 };
302 let place = check!(self.place(&a.actor, &a.owner, true).await?);
303 if a.projects.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" {
304 return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose projects."));
305 }
306 let rows = self.rows(&place.owner).await?;
307 // A secret and a variable may share a key, as on GitHub, where
308 // workflows read them apart (`secrets.X`, `vars.X`).
309 let same_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect();
310 // The row being changed: by id, else the key's row for every
311 // environment (GitHub's API names a secret by its key alone).
312 let existing = match &a.id {
313 Some(id) => match rows.iter().find(|row| &row.id == id) {
314 Some(row) => Some(row),
315 None => return Ok(fail(FailureCode::NotFound, "There is no such row.")),
316 },
317 None if a.environments.is_none() => same_key.iter().copied().find(|row| row.environments.is_empty() && row.kind == kind),
318 None => None,
319 };
320 if existing.is_some_and(|row| row.kind == "secret" && kind == "variable") {
321 return Ok(fail(FailureCode::Invalid, "A secret cannot become config: its value is sealed. Add a config row and remove the secret."));
322 }
323 let environments = environments.unwrap_or_else(|| existing.map(SettingRow::environments).unwrap_or_default());
324 // A key's rows never apply to the same environment twice.
325 if let Some(clash) = same_key
326 .iter()
327 .find(|row| row.kind == kind && existing.is_none_or(|e| e.id != row.id) && row.overlaps(&environments))
328 {
329 let at = if clash.environments.is_empty() { "all environments".to_owned() } else { clash.environments.replace(',', ", ") };
330 let what = if kind == "secret" { "secret" } else { "config" };
331 return Ok(fail(
332 FailureCode::Conflict,
333 format!("{name} already has a {what} row for {at}. Edit that row, or choose other environments."),
334 ));
335 }
336 if existing.is_none() && rows.len() as u32 >= MAX_PER_OWNER {
337 return Ok(fail(FailureCode::Invalid, format!("There can be at most {MAX_PER_OWNER} secrets and variables here.")));
338 }
339 let id = existing.map(|row| row.id.clone()).unwrap_or_else(|| new_id("set", now_ms()));
340 let value = match (&a.value, existing) {
341 (Some(value), _) if kind == "secret" => check!(self.seal(value, &id)),
342 (Some(value), _) => value.clone(),
343 // Config becoming a secret: its value is sealed now.
344 (None, Some(row)) if row.kind == "variable" && kind == "secret" => check!(self.seal(&row.value, &id)),
345 (None, Some(row)) => row.value.clone(),
346 (None, None) => return Ok(fail(FailureCode::Invalid, "A new row needs a `value`.")),
347 };
348 let available_to = readers
349 .map(|r| r.join(","))
350 .or_else(|| existing.map(|row| row.available_to.clone()))
351 .unwrap_or_else(|| CONSUMERS.join(","));
352 let repositories: Option<String> = match &a.projects {
353 Some(list) if list.is_empty() => None,
354 Some(list) => Some(serde_json::to_string(list).unwrap_or_default()),
355 None => existing.and_then(|row| row.repositories.clone()),
356 };
357 let note = match &a.note {
358 Some(note) if note.trim().is_empty() => None,
359 Some(note) => Some(note.trim().to_owned()),
360 None => existing.and_then(|row| row.note.clone()),
361 };
362 let at = rfc3339(now_ms());
363 let optional = |v: Option<&str>| v.map_or(JsValue::NULL, JsValue::from);
364 self.db
365 .prepare(
366 "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by)
367 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
368 ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, value = excluded.value, updated_at = excluded.updated_at,
369 available_to = excluded.available_to, environments = excluded.environments,
370 repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by",
371 )
372 .bind(&[
373 id.as_str().into(),
374 place.scope.into(),
375 place.owner.as_str().into(),
376 kind.into(),
377 name.as_str().into(),
378 value.into(),
379 at.as_str().into(),
380 available_to.as_str().into(),
381 environments.join(",").into(),
382 optional(repositories.as_deref()),
383 optional(note.as_deref()),
384 a.actor.username.as_str().into(),
385 ])?
386 .run()
387 .await?;
388 let row = self
389 .db
390 .prepare("SELECT * FROM settings WHERE id = ?")
391 .bind(&[id.as_str().into()])?
392 .first::<SettingRow>(None)
393 .await?
394 .expect("just written");
395 Ok(Outcome::Ok(row.describe()))
396 }
397
398 pub async fn delete_setting(&self, a: DeleteSettingArgs) -> Result<Outcome<bool>> {
399 let kind = check!(Self::kind(&a.kind));
400 let place = check!(self.place(&a.actor, &a.owner, true).await?);
401 let name = a.name.trim().to_ascii_uppercase();
402 let removed = match &a.id {
403 Some(id) => self
404 .db
405 .prepare("DELETE FROM settings WHERE owner = ? AND id = ? RETURNING id")
406 .bind(&[place.owner.as_str().into(), id.as_str().into()])?
407 .all()
408 .await?,
409 None => self
410 .db
411 .prepare("DELETE FROM settings WHERE owner = ? AND name = ? AND (?3 IS NULL OR kind = ?3) RETURNING id")
412 .bind(&[place.owner.as_str().into(), name.as_str().into(), kind.map_or(JsValue::NULL, JsValue::from)])?
413 .all()
414 .await?,
415 };
416 Ok(if removed.results::<Value>()?.is_empty() {
417 fail(FailureCode::NotFound, format!("There is nothing called {} here.", a.name))
418 } else {
419 Outcome::Ok(true)
420 })
421 }
422
423 /// What one reader of a repository gets: per key, the row for
424 /// `environment`, else the row for every environment; the repository's
425 /// over its workspace's. No secrets unless `trusted`.
426 #[allow(clippy::too_many_arguments)]
427 async fn resolved(
428 &self,
429 project_id: &str,
430 project_slug: &str,
431 namespace: &str,
432 kind: &str,
433 consumer: &str,
434 environment: Option<&str>,
435 trusted: bool,
436 ) -> Result<Map<String, Value>> {
437 if kind == "secret" && !trusted {
438 return Ok(Map::new());
439 }
440 let environment = environment.map(str::to_ascii_lowercase);
441 let mut out = Map::new();
442 for owner in [namespace.to_lowercase(), project_id.to_owned()] {
443 let rows: Vec<SettingRow> = self
444 .rows(&owner)
445 .await?
446 .into_iter()
447 .filter(|row| row.kind == kind)
448 .filter(|row| split(&row.available_to).iter().any(|r| r == consumer))
449 .filter(|row| row.scope != "workspace" || row.reaches(project_slug))
450 .collect();
451 let mut names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
452 names.dedup();
453 for name in names {
454 let of_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect();
455 let chosen = environment
456 .as_deref()
457 .and_then(|env| of_key.iter().find(|row| row.environments().iter().any(|e| e == env)))
458 .or_else(|| of_key.iter().find(|row| row.environments.is_empty()));
459 let Some(row) = chosen else {
460 // Rows only for other environments: this reader gets
461 // none, nor the workspace's.
462 out.remove(name);
463 continue;
464 };
465 let value = if kind == "secret" {
466 match self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)) {
467 Some(value) => value,
468 None => continue,
469 }
470 } else {
471 row.value.clone()
472 };
473 out.insert(name.to_owned(), Value::String(value));
474 }
475 }
476 Ok(out)
477 }
478
479 /// The `vars` context of a repository's runs. `environment` is the job's
480 /// `environment:`, when it has one.
481 pub async fn variables_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> {
482 let (namespace, _) = repo.split_once('/').unwrap_or((repo, ""));
483 let Some(project) = self.project_of(repo_id).await? else {
484 return Ok(Map::new());
485 };
486 self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await
487 }
488
489 /// The `secrets` context of a repository's runs, opened.
490 pub async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> {
491 let (namespace, _) = repo.split_once('/').unwrap_or((repo, ""));
492 let Some(project) = self.project_of(repo_id).await? else {
493 return Ok(Map::new());
494 };
495 self.resolved(&project.id, &project.slug, namespace, "secret", "workflows", environment, trusted).await
496 }
497
498 /// `resolve_settings`, for the deployments service: what a deploy build
499 /// and its running app get.
500 pub async fn resolve_settings(&self, a: ResolveSettingsArgs) -> Result<ResolvedSettings> {
501 let environment = a.environment.as_deref();
502 // Rows kept under the repository from before projects move to its
503 // primary project first, however the project is named here.
504 let primary = self.project_of(&a.repo_id).await?;
505 let (project_id, slug) = match (a.project_id, a.project_slug, primary) {
506 (Some(id), Some(slug), _) => (id, slug),
507 (_, _, Some(project)) => (project.id, project.slug),
508 _ => return Ok(ResolvedSettings::default()),
509 };
510 Ok(ResolvedSettings {
511 secrets: self
512 .resolved(&project_id, &slug, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted)
513 .await?,
514 variables: self
515 .resolved(&project_id, &slug, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted)
516 .await?,
517 })
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::{SettingRow, consumers, valid_environments, valid_name};
524
525 fn row(environments: &str) -> SettingRow {
526 SettingRow {
527 id: "set_1".into(),
528 scope: "repository".into(),
529 kind: "secret".into(),
530 name: "STRIPE_KEY".into(),
531 value: String::new(),
532 updated_at: String::new(),
533 available_to: "workflows,deployments".into(),
534 environments: environments.into(),
535 repositories: None,
536 note: None,
537 updated_by: None,
538 }
539 }
540
541 #[test]
542 fn names_follow_githubs_rules_and_keep_g1ts_own() {
543 assert_eq!(valid_name("npm_token").unwrap(), "NPM_TOKEN");
544 assert!(valid_name("GITHUB_TOKEN").is_err());
545 assert!(valid_name("G1T_TOKEN").is_err());
546 assert!(valid_name("1PASSWORD").is_err());
547 assert!(valid_name("MY-TOKEN").is_err());
548 assert!(valid_name("").is_err());
549 }
550
551 #[test]
552 fn environments_and_readers_are_checked() {
553 assert_eq!(valid_environments(&["Production".into(), "preview".into(), "production".into()]).unwrap(), vec!["preview", "production"]);
554 assert!(valid_environments(&["staging env".into()]).is_err());
555 assert_eq!(consumers(&["Deployments".into(), "deployments".into()]).unwrap(), vec!["deployments"]);
556 assert!(consumers(&["agents".into()]).is_err());
557 assert!(consumers(&[]).is_err());
558 }
559
560 #[test]
561 fn a_keys_rows_cannot_share_an_environment() {
562 assert!(row("production").overlaps(&["production".into(), "preview".into()]));
563 assert!(!row("production").overlaps(&["preview".into()]));
564 // One row for every environment, and others for some, live together.
565 assert!(!row("").overlaps(&["preview".into()]));
566 assert!(row("").overlaps(&[]));
567 }
568}

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