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

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1//! Security updates: for each vulnerable package with a fix, g1t itself
2//! opens a pull request that raises its version.
3//!
4//! 1. A dependency scan asks the runner for a `bump` (most severe first):
5//! a sandbox raises the package in each lockfile with the ecosystem's
6//! own tool and pushes that to `g1t/security/<package>-<version>`.
7//! 2. That push (`git.push`) opens the pull request, authored by g1t
8//! (`User::system`). It lands through the branch's required checks like
9//! any other. An older one for the same package is closed as superseded.
10//! 3. When its checks fail because code has to change, or the sandbox
11//! never pushed, the pull request is closed and an issue is opened for
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent12//! g1t to work on, started by g1t. That is the only time an agent is
13//! used.
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches14//! 4. Once none of the alerts it fixes is open (fixed on the default
15//! branch, or dismissed), its pull request is closed with a comment
16//! saying why and its branch deleted (`resolved`).
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily17//!
18//! Each step is written to the alerts' activity log.
19
20use std::collections::BTreeMap;
21
22use g1t_contracts::repos::RepoPath;
23use g1t_contracts::security::{BumpArgs, UpdateState, update_branch};
24use g1t_contracts::time::rfc3339;
25use g1t_contracts::work::{OpenIssueArgs, OpenPullArgs, Pull, PullActionArgs, PullDetail, PullStatus, Runtime, ViewArgs};
26use g1t_contracts::{Outcome, User};
27use g1t_kit::now_ms;
28use g1t_scan::osv::{self, Severity};
29use g1t_scan::version;
30use serde_json::{Value, json};
31use worker::Result;
32
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar33use g1t_contracts::security::UPDATE_BRANCH_PREFIX;
34use g1t_contracts::updates::{BumpPackage, IgnoreCondition, UpdatedDependency, VersionUpdateEntry, VersionUpdatesState};
35use g1t_contracts::work::UpdatePullArgs;
36
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily37use crate::Security;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar38use crate::config::{CommitMessage, Config, Entry, glob, matches};
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily39use crate::deps::{advisory_table, issue_text};
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar40use crate::pull_text;
41use crate::ranges;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily42use crate::store::{Activity, RepoRow, UpdateRow, VulnRow};
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar43use crate::update_store::NewPull;
44use crate::version_updates::{Loaded, package_ecosystem};
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily45
46/// Security updates asked for per scan, most severe first.
47const MAX_NEW_UPDATES: usize = 8;
48/// A sandbox that has not pushed its branch after this long is taken to
49/// have failed.
50const STALLED_MS: u64 = 45 * 60 * 1000;
51/// A failed update is tried again after this long.
52const RETRY_FAILED_MS: u64 = 24 * 60 * 60 * 1000;
53
54/// What to do about a package's existing update, given the version it
55/// should now reach.
56#[derive(Debug, PartialEq, Eq)]
57pub enum Next {
58 /// Leave it: in flight, done, or someone closed it.
59 Wait,
60 /// Ask for a new one.
61 Request,
62}
63
64/// Whether a package with an update in `state` to `current`, last changed
65/// at `updated_at`, needs a new one to reach `target`. A higher target
66/// always does; the same one only when the last was superseded (it is
67/// vulnerable again) or failed long enough ago.
68pub fn next_step(state: UpdateState, current: &str, target: &str, updated_at: &str, retry_after: &str) -> Next {
69 if version::compare(target, current) == std::cmp::Ordering::Greater {
70 return Next::Request;
71 }
72 match state {
73 UpdateState::Superseded => Next::Request,
74 UpdateState::Failed if updated_at < retry_after => Next::Request,
75 _ => Next::Wait,
76 }
77}
78
79/// The pull request's title.
80pub fn pull_title(package: &str, target: &str) -> String {
81 format!("Upgrade {package} to {target}").chars().take(200).collect()
82}
83
84/// The pull request's body: what it fixes, where, and what happens if
85/// raising the version is not enough.
86pub fn pull_text(ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> String {
87 let mut from: Vec<&str> = vulns.iter().map(|vuln| vuln.version.as_str()).collect();
88 from.sort();
89 from.dedup();
90 let mut lockfiles: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
91 lockfiles.sort();
92 lockfiles.dedup();
93 let mut body = format!(
94 "Upgrades `{package}` ({ecosystem}) from {} to **{target}**, which fixes these known vulnerabilities:\n\n",
95 from.join(", ")
96 );
97 body.push_str(&advisory_table(vulns));
98 body.push_str(&format!(
99 "\nLockfiles changed: {}.\n\n\
100 Only the version changes. This pull request lands through this branch's required checks like any other. \
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent101 If they fail because code has to change, g1t closes it and puts g1t on an issue to make the change.\n\n\
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily102 ---\n_Opened by g1t's security updates. Turn them off for this project on its Security page._",
103 lockfiles.iter().map(|path| format!("`{path}`")).collect::<Vec<_>>().join(", ")
104 ));
105 body
106}
107
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar108/// A package a `security-updates` group gathers.
109pub struct GroupMember {
110 /// OSV's name.
111 pub ecosystem: String,
112 pub package: String,
113 /// The lowest vulnerable version found.
114 pub from: String,
115 pub target: String,
116 /// The lockfiles that resolve a vulnerable version.
117 pub manifests: Vec<String>,
118}
119
120fn lowest(vulns: &[&VulnRow]) -> String {
121 vulns.iter().map(|vuln| vuln.version.clone()).min_by(|a, b| version::compare(a, b)).unwrap_or_default()
122}
123
124/// Whether one of the file's directories (`/web`, or a glob) holds the
125/// lockfile at `path`, from the root.
126fn covers(directories: &[String], path: &str) -> bool {
127 let directory = format!("/{}", path.rsplit_once('/').map_or("", |(dir, _)| dir));
128 directories.iter().any(|wanted| if wanted.contains(['*', '?']) { glob(wanted, &directory) } else { *wanted == directory })
129}
130
131/// The `updates` entry that speaks for a vulnerable package: its
132/// ecosystem, a directory holding one of its lockfiles, and no
133/// `target-branch` but the default branch (security updates always go to
134/// the default branch, and such an entry's options are for version
135/// updates only).
136pub fn security_entry<'a>(config: &'a Config, default_branch: &str, osv: &str, manifests: &[&str]) -> Option<&'a Entry> {
137 let ecosystem = package_ecosystem(osv)?;
138 config.updates.iter().find(|entry| {
139 entry.ecosystem == ecosystem
140 && entry.target_branch.as_deref().is_none_or(|branch| branch == default_branch)
141 && manifests.iter().any(|path| covers(&entry.directories, path))
142 })
143}
144
145/// Whether the file lets a security update raise `package` to `target`:
146/// not ignored (by name, or for these versions) in the file or by a
147/// comment, and named by `allow` when it names dependencies.
148pub fn security_allowed(entry: &Entry, comments: &[IgnoreCondition], package: &str, target: &str) -> bool {
149 let ignored = entry.ignore.iter().filter(|rule| matches(&rule.dependency, package)).any(|rule| {
150 (rule.versions.is_empty() && rule.update_types.is_empty()) || rule.versions.iter().any(|versions| ranges::ignored_by(versions, target))
151 });
152 let commented = comments.iter().filter(|c| c.ecosystem == entry.ecosystem && c.dependency.eq_ignore_ascii_case(package)).any(|c| {
153 (c.versions.is_none() && c.update_type.is_none()) || c.versions.as_deref().is_some_and(|versions| ranges::ignored_by(versions, target))
154 });
155 let named: Vec<&str> = entry.allow.iter().filter_map(|rule| rule.dependency.as_deref()).collect();
156 let allowed = named.is_empty() || named.iter().any(|pattern| matches(pattern, package));
157 !ignored && !commented && allowed
158}
159
160/// The `security-updates` group that gathers `package`, if any.
161pub fn security_group(entry: &Entry, package: &str, from: &str, target: &str) -> Option<String> {
162 let level = ranges::update_level(from, target);
163 entry
164 .groups
165 .iter()
166 .filter(|group| group.applies_to == "security-updates")
167 .find(|group| {
168 (group.patterns.is_empty() || group.patterns.iter().any(|pattern| matches(pattern, package)))
169 && !group.exclude_patterns.iter().any(|pattern| matches(pattern, package))
170 && (group.update_types.is_empty() || group.update_types.iter().any(|wanted| wanted == level))
171 })
172 .map(|group| group.name.clone())
173}
174
175/// The entry, as last read, that speaks for a package's security update.
176pub fn security_settings<'a>(state: &'a VersionUpdatesState, osv: &str, manifests: &[&str]) -> Option<&'a VersionUpdateEntry> {
177 let ecosystem = package_ecosystem(osv)?;
178 state.updates.iter().find(|entry| {
179 entry.ecosystem == ecosystem && entry.target_branch.is_none() && manifests.iter().any(|path| covers(&entry.directories, path))
180 })
181}
182
183/// An entry's `commit-message`, as last read.
184pub fn commit_message_of(entry: &VersionUpdateEntry) -> Option<CommitMessage> {
185 let message = entry.options.get("commit-message")?;
186 let text = |key: &str| message.get(key).and_then(Value::as_str).map(str::to_owned);
187 Some(CommitMessage { prefix: text("prefix"), prefix_development: text("prefix-development"), scope: text("include").as_deref() == Some("scope") })
188}
189
190/// A title with the file's commit message prefix, if it has one.
191fn prefixed(prefix: &str, plain: String) -> String {
192 let text = if prefix.is_empty() { plain } else { format!("{prefix}{}{}", plain[..1].to_lowercase(), &plain[1..]) };
193 text.chars().take(200).collect()
194}
195
196/// A grouped security update's body.
197pub fn group_text(group: &str, members: &[GroupMember], vulns: &[&VulnRow], file: &str) -> String {
198 let mut body = format!("Upgrades the {group} group's vulnerable packages to the versions that fix them:\n\n| Package | From | To |\n| --- | --- | --- |\n");
199 for member in members {
200 body.push_str(&format!("| `{}` | {} | **{}** |\n", member.package, member.from, member.target));
201 }
202 body.push_str("\nThe known vulnerabilities they fix:\n\n");
203 body.push_str(&advisory_table(vulns));
204 body.push_str(&format!(
205 "\nOnly the versions change. This pull request lands through this branch's required checks like any other. \
206 If they fail because code has to change, g1t closes it and puts g1t on an issue to make the change.\n\n{}\n\n---\n\
207 _Opened by g1t's security updates, grouped as `{file}` asks. Turn them off for this project on its Security page._",
208 pull_text::COMMANDS
209 ));
210 body
211}
212
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily213impl Security {
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches214 pub(crate) fn path_of(repo: &RepoRow) -> RepoPath {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily215 RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }
216 }
217
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches218 pub(crate) async fn get_pull(&self, repo: &RepoRow, number: u32) -> Result<Option<Pull>> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily219 let found: Outcome<PullDetail> = g1t_kit::call(
220 &self.work,
221 "get_pull",
222 &ViewArgs { repo: Self::path_of(repo), number, viewer: Some(User::system(&repo.namespace)), after_seq: 0 },
223 )
224 .await?;
225 Ok(found.into_result().ok().map(|detail| detail.pull))
226 }
227
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches228 /// Closes one of g1t's pull requests, saying why first. Returns
229 /// whether it closed.
230 pub(crate) async fn close_with(&self, repo: &RepoRow, number: u32, why: String) -> Result<bool> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily231 let system = User::system(&repo.namespace);
232 self.comment(&system, &Self::path_of(repo), number, why).await?;
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches233 let closed: Outcome<Value> = g1t_kit::call(
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily234 &self.work,
235 "close_pull",
236 &PullActionArgs {
237 actor: system,
238 repo: Self::path_of(repo),
239 number,
240 summary: String::new(),
241 keep_issue_open: false,
242 ignore_checks: false,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge243 bypass_rules: false,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily244 },
245 )
246 .await?;
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches247 if let Outcome::Fail(refused) = &closed {
248 worker::console_error!("security: #{number} of {} not closed: {}", repo.repo_id, refused.message);
249 }
250 Ok(matches!(closed, Outcome::Ok(_)))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily251 }
252
253 /// Records `action` on every open alert of an update's package.
254 async fn note(&self, row: &UpdateRow, action: &str, actor: Option<&str>, number: Option<u32>, comment: Option<&str>) -> Result<()> {
255 let ids = self.store.open_ids(&row.repo_id, &row.ecosystem, &row.package).await?;
256 let activity: Vec<Activity> = ids
257 .iter()
258 .map(|id| Activity { alert_id: id, action, actor, reason: None, comment, number })
259 .collect();
260 self.store.record(&row.repo_id, &activity).await
261 }
262
263 /// After a dependency scan: asks for an update for each vulnerable
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches264 /// package with a fix (when `enabled`). Those no longer needed were
265 /// closed already (`resolved`).
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar266 ///
267 /// The dependency update file, when there is one, applies as it does to
268 /// version updates: `ignore` and `allow` by name, people's `@g1t ignore`
269 /// comments, `groups` with `applies-to: security-updates` (one pull
270 /// request for each group), and `assignees`, `reviewers` and
271 /// `commit-message`. `open-pull-requests-limit` does not apply.
272 pub async fn security_updates(&self, repo: &RepoRow, enabled: bool, rules: Option<&Loaded>) -> Result<()> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily273 let open = self.store.open_vulnerabilities(&repo.repo_id).await?;
274 let mut by_package: BTreeMap<(String, String), Vec<&VulnRow>> = BTreeMap::new();
275 for vuln in &open {
276 by_package.entry((vuln.ecosystem.clone(), vuln.package.clone())).or_default().push(vuln);
277 }
278 if !enabled {
279 return Ok(());
280 }
281 let mut groups: Vec<((String, String), Vec<&VulnRow>)> = by_package
282 .into_iter()
283 .filter(|(_, vulns)| vulns.iter().any(|vuln| vuln.fixed_version.is_some()))
284 .collect();
285 groups.sort_by_key(|(_, vulns)| std::cmp::Reverse(vulns.iter().map(|v| Severity::parse(&v.severity)).max()));
286 let retry_after = rfc3339(now_ms().saturating_sub(RETRY_FAILED_MS));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar287 let comments = self.store.ignores(&repo.repo_id).await?;
288 let mut grouped: BTreeMap<(String, String), Vec<GroupMember>> = BTreeMap::new();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily289 let mut asked = 0;
290 for ((ecosystem, package), vulns) in groups {
291 if asked >= MAX_NEW_UPDATES {
292 break;
293 }
294 let Some(target) = osv::upgrade_target(vulns.iter().filter_map(|v| v.fixed_version.as_deref())) else {
295 continue;
296 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar297 let manifests: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
298 if let Some(entry) = rules.and_then(|loaded| security_entry(&loaded.config, &loaded.default_branch, &ecosystem, &manifests)) {
299 if !security_allowed(entry, &comments, &package, &target) {
300 continue;
301 }
302 let from = lowest(&vulns);
303 if let Some(group) = security_group(entry, &package, &from, &target) {
304 let key = (entry.id(), group);
305 if !grouped.contains_key(&key) {
306 asked += 1;
307 }
308 let manifests = manifests.iter().map(|path| (*path).to_owned()).collect();
309 grouped.entry(key).or_default().push(GroupMember { ecosystem: ecosystem.clone(), package: package.clone(), from, target, manifests });
310 continue;
311 }
312 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily313 match self.store.update(&repo.repo_id, &ecosystem, &package).await? {
314 Some(row) => {
315 if next_step(row.state(), &row.target, &target, &row.updated_at, &retry_after) == Next::Wait {
316 continue;
317 }
318 }
319 None => {
320 // An upgrade issue from before security updates, still
321 // open, is left to the agent on it.
322 if let Some(existing) = self.store.upgrade(&repo.repo_id, &ecosystem, &package).await?
323 && self
324 .issue(&User::system(&repo.namespace), &Self::path_of(repo), existing.number as u32)
325 .await?
326 .is_some_and(|issue| issue.state == g1t_contracts::work::State::Open)
327 {
328 continue;
329 }
330 }
331 }
332 asked += 1;
333 self.request(repo, &ecosystem, &package, &target, &vulns).await?;
334 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar335 for ((entry_id, group), members) in grouped {
336 let Some(loaded) = rules else { continue };
337 let Some(entry) = loaded.config.updates.iter().find(|entry| entry.id() == entry_id) else { continue };
338 self.request_group(repo, loaded, entry, &group, members, &open).await?;
339 }
340 Ok(())
341 }
342
343 /// Asks for one pull request for a `security-updates` group's
344 /// packages, unless one for the same versions is under way or was
345 /// closed.
346 async fn request_group(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry, group: &str, members: Vec<GroupMember>, open: &[VulnRow]) -> Result<()> {
347 let subject = format!("security:{group}");
348 let mut signature: Vec<String> = members.iter().map(|m| format!("{}@{}", m.package, m.target)).collect();
349 signature.sort();
350 let signature = signature.join(",");
351 let existing = self.store.update_pulls(&repo.repo_id).await?;
352 let known = |row: &&crate::update_store::PullRow| row.kind == "security" && row.subject == subject && row.signature == signature;
353 if existing.iter().filter(known).any(|row| row.state().in_progress() || row.state() == UpdateState::Closed) {
354 return Ok(());
355 }
356 let branch = format!("{UPDATE_BRANCH_PREFIX}{}-{}", group.to_lowercase().replace('|', "-"), pull_text::digest(&signature));
357 let vulns: Vec<&VulnRow> = open.iter().filter(|vuln| members.iter().any(|m| m.ecosystem == vuln.ecosystem && m.package == vuln.package)).collect();
358 let count = if members.len() == 1 { "1 security update".to_owned() } else { format!("{} security updates", members.len()) };
359 let title = prefixed(&pull_text::prefix(entry.commit_message.as_ref(), false), format!("Bump the {group} group with {count}"));
360 let body = group_text(group, &members, &vulns, &loaded.file);
361 let mut lockfiles: Vec<String> = members.iter().flat_map(|m| m.manifests.clone()).collect();
362 lockfiles.sort();
363 lockfiles.dedup();
364 let packages: Vec<BumpPackage> = members.iter().map(|m| BumpPackage { package: m.package.clone(), version: m.target.clone() }).collect();
365 let bump = BumpArgs {
366 repo: Self::path_of(repo),
367 ecosystem: members[0].ecosystem.clone(),
368 package: packages[0].package.clone(),
369 version: packages[0].version.clone(),
370 lockfiles,
371 branch: branch.clone(),
372 message: title.clone(),
373 kind: None,
374 packages,
375 strategy: None,
376 force: existing.iter().any(|row| row.branch == branch && row.state().in_progress()),
377 registries: Vec::new(),
378 base: None,
379 };
380 let dependencies: Vec<UpdatedDependency> = members
381 .iter()
382 .map(|m| UpdatedDependency {
383 name: m.package.clone(),
384 from: m.from.clone(),
385 to: m.target.clone(),
386 directory: m.manifests.first().map(|path| format!("/{}", path.rsplit_once('/').map_or("", |(dir, _)| dir))).unwrap_or_else(|| "/".to_owned()),
387 dependency_type: String::new(),
388 update_type: ranges::update_type(&m.from, &m.target),
389 })
390 .collect();
391 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
392 let id = self
393 .store
394 .add_update_pull(&NewPull {
395 repo_id: &repo.repo_id,
396 kind: "security",
397 entry: &entry.id(),
398 ecosystem: &entry.ecosystem,
399 subject: &subject,
400 signature: &signature,
401 group: Some(group),
402 branch: &branch,
403 title: &title,
404 body: &body,
405 dependencies: &dependencies,
406 bump: &bump,
407 assignees: &people(&entry.assignees),
408 reviewers: &people(&entry.reviewers),
409 })
410 .await?;
411 for member in &members {
412 self.store.request_update(&repo.repo_id, &member.ecosystem, &member.package, &member.target, &branch).await?;
413 }
414 let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &bump).await {
415 Ok(Outcome::Ok(_)) => Ok(()),
416 Ok(Outcome::Fail(refused)) => Err(refused.message),
417 Err(error) => Err(format!("the runner could not be reached: {error}")),
418 };
419 for member in &members {
420 let Some(row) = self.store.update(&repo.repo_id, &member.ecosystem, &member.package).await? else { continue };
421 match &started {
422 Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await?,
423 Err(reason) => {
424 let error = format!("g1t could not start the security update: {reason}");
425 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
426 self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await?;
427 }
428 }
429 }
430 if let Err(reason) = started {
431 self.store.set_update_pull(&id, UpdateState::Failed, None, None, Some(&format!("g1t could not start the security update: {reason}"))).await?;
432 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily433 Ok(())
434 }
435
436 /// Asks the runner to make the change on its branch.
437 async fn request(&self, repo: &RepoRow, ecosystem: &str, package: &str, target: &str, vulns: &[&VulnRow]) -> Result<()> {
438 let branch = update_branch(package, target);
439 let mut lockfiles: Vec<String> = vulns.iter().map(|vuln| vuln.manifest.clone()).collect();
440 lockfiles.sort();
441 lockfiles.dedup();
442 let args = BumpArgs {
443 repo: Self::path_of(repo),
444 ecosystem: ecosystem.to_owned(),
445 package: package.to_owned(),
446 version: target.to_owned(),
447 lockfiles,
448 branch: branch.clone(),
449 message: format!("Upgrade {package} to {target}"),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar450 kind: None,
451 packages: Vec::new(),
452 strategy: None,
453 force: false,
454 registries: Vec::new(),
455 base: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily456 };
457 let started: std::result::Result<(), String> = match g1t_kit::call::<_, Outcome<bool>>(&self.runner, "bump", &args).await {
458 Ok(Outcome::Ok(_)) => Ok(()),
459 Ok(Outcome::Fail(refused)) => Err(refused.message),
460 Err(error) => Err(format!("the runner could not be reached: {error}")),
461 };
462 self.store.request_update(&repo.repo_id, ecosystem, package, target, &branch).await?;
463 let Some(row) = self.store.update(&repo.repo_id, ecosystem, package).await? else {
464 return Ok(());
465 };
466 match started {
467 Ok(()) => self.note(&row, "update_requested", Some(g1t_contracts::system::USERNAME), None, None).await,
468 Err(reason) => {
469 let error = format!("g1t could not start the security update: {reason}");
470 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
471 self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await
472 }
473 }
474 }
475
476 /// A security update's branch was pushed: its pull request opens, and
477 /// an older one for the same package is closed as superseded.
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches478 pub async fn update_pushed(&self, repo_id: &str, branch: &str, after: &str) -> Result<()> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily479 let Some(row) = self.store.update_by_branch(repo_id, branch).await? else {
480 return Ok(());
481 };
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches482 if row.state() == UpdateState::Superseded && row.pull().is_none() {
483 // No longer needed by the time its sandbox pushed: the branch goes.
484 return self.drop_pushed(repo_id, branch, after).await;
485 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily486 if row.state() != UpdateState::Requested {
487 return Ok(());
488 }
489 let Some(repo) = self.store.repo(repo_id).await? else {
490 return Ok(());
491 };
492 let open = self.store.open_vulnerabilities(repo_id).await?;
493 let vulns: Vec<&VulnRow> = open
494 .iter()
495 .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package)
496 .collect();
497 let system = User::system(&repo.namespace);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar498 // What the dependency update file says for this package's directory.
499 let state = repo.version_updates();
500 let manifests: Vec<&str> = vulns.iter().map(|vuln| vuln.manifest.as_str()).collect();
501 let settings = security_settings(&state, &row.ecosystem, &manifests);
502 let prefix = settings.map(|entry| pull_text::prefix(commit_message_of(entry).as_ref(), false)).unwrap_or_default();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily503 let opened: Outcome<Pull> = g1t_kit::call(
504 &self.work,
505 "open_pull",
506 &OpenPullArgs {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar507 actor: system.clone(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily508 repo: Self::path_of(&repo),
509 issue: None,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar510 title: prefixed(&prefix, pull_title(&row.package, &row.target)),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily511 body: pull_text(&row.ecosystem, &row.package, &row.target, &vulns),
512 branch: Some(branch.to_owned()),
513 agent: String::new(),
514 runtime: Runtime::External,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar515 // Security updates are for the default branch.
516 base: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily517 },
518 )
519 .await?;
520 let pull = match opened {
521 Outcome::Ok(pull) => pull,
522 Outcome::Fail(refused) => {
523 let error = format!("The pull request could not be opened: {}", refused.message);
524 self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
525 return self.note(&row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await;
526 }
527 };
Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches528 if let Some(older) = row.pull().filter(|older| *older != pull.number)
529 && let Some(found) = self.get_pull(&repo, older).await?
530 && matches!(found.status, PullStatus::Open | PullStatus::Draft)
531 && self
532 .close_with(&repo, older, format!("Closed: superseded by #{}, which upgrades `{}` to {}.", pull.number, row.package, row.target))
533 .await?
534 && found.branch.as_deref() != Some(branch)
535 {
536 self.delete_pull_branch(&repo, &Pull { status: PullStatus::Closed, ..found }).await;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily537 }
538 self.store.set_update(&row, UpdateState::Open, Some(pull.number), None, None).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar539 // Labelled as the entry for its directory says, or `dependencies`
540 // and its ecosystem's label; assigned and in a milestone as it says.
541 let ecosystem = package_ecosystem(&row.ecosystem).unwrap_or(row.ecosystem.as_str());
542 let labels = crate::config::update_labels(settings.and_then(|entry| entry.labels.as_deref()), ecosystem);
543 let people = |names: &[String]| -> Vec<String> { names.iter().filter(|name| !name.contains('/')).cloned().collect() };
544 let (assignees, reviewers) = settings.map(|entry| (people(&entry.assignees), people(&entry.reviewers))).unwrap_or_default();
545 if !assignees.is_empty() || !reviewers.is_empty() || !labels.is_empty() {
546 let _: Outcome<Value> = g1t_kit::call(
547 &self.work,
548 "update_pull",
549 &UpdatePullArgs {
550 actor: system.clone(),
551 repo: Self::path_of(&repo),
552 number: pull.number,
553 assignees: (!assignees.is_empty()).then_some(assignees),
554 reviewers: (!reviewers.is_empty()).then_some(reviewers),
555 labels: (!labels.is_empty()).then_some(labels),
556 milestone: None,
557 base: None,
558 },
559 )
560 .await?;
561 }
562 if let Some(milestone) = settings.and_then(|entry| entry.milestone) {
563 let _: Outcome<Value> = g1t_kit::call(
564 &self.work,
565 "update_pull",
566 &UpdatePullArgs {
567 actor: system,
568 repo: Self::path_of(&repo),
569 number: pull.number,
570 assignees: None,
571 reviewers: None,
572 labels: None,
573 milestone: Some(milestone),
574 base: None,
575 },
576 )
577 .await?;
578 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily579 self.note(&row, "update_opened", Some(g1t_contracts::system::USERNAME), Some(pull.number), None).await
580 }
581
582 /// A pull request merged, closed or checked: if it is a security
583 /// update's, where the update stands now.
584 pub async fn update_pull_event(&self, kind: &str, repo_id: &str, number: u32, status: Option<&str>, actor: Option<&str>) -> Result<()> {
585 let Some(row) = self.store.update_by_pull(repo_id, number).await? else {
586 return Ok(());
587 };
588 if row.state() != UpdateState::Open {
589 return Ok(());
590 }
591 match kind {
592 "pull.merged" => {
593 self.store.set_update(&row, UpdateState::Merged, Some(number), None, None).await?;
594 self.note(&row, "update_merged", actor, Some(number), None).await
595 }
596 "pull.closed" => {
597 self.store.set_update(&row, UpdateState::Closed, Some(number), None, None).await?;
598 self.note(&row, "update_closed", actor, Some(number), None).await
599 }
600 "checks.completed" if status == Some("failed") => {
601 let Some(repo) = self.store.repo(repo_id).await? else {
602 return Ok(());
603 };
604 self.needs_code(&repo, &row, "Raising the version alone fails this branch's required checks").await
605 }
606 _ => Ok(()),
607 }
608 }
609
610 /// Closes an update that is no longer needed: its pull request, if it
611 /// has one still open (one that merged meanwhile is recorded merged).
612 async fn supersede(&self, repo: &RepoRow, row: &UpdateRow, why: String) -> Result<()> {
613 if let Some(number) = row.pull() {
614 match self.get_pull(repo, number).await? {
615 Some(pull) if pull.status == PullStatus::Merged => {
616 return self.store.set_update(row, UpdateState::Merged, Some(number), row.issue(), None).await;
617 }
618 Some(pull) if matches!(pull.status, PullStatus::Open | PullStatus::Draft) => {
619 self.store.set_update(row, UpdateState::Superseded, Some(number), row.issue(), None).await?;
620 self.close_with(repo, number, why).await?;
621 return self.note(row, "update_superseded", Some(g1t_contracts::system::USERNAME), Some(number), None).await;
622 }
623 _ => {}
624 }
625 }
626 self.store.set_update(row, UpdateState::Superseded, row.pull(), row.issue(), None).await
627 }
628
629 /// Updates whose sandbox never pushed: raising the version did not
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent630 /// work, so g1t gets an issue for it.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily631 pub async fn stalled_updates(&self) -> Result<()> {
632 let before = rfc3339(now_ms().saturating_sub(STALLED_MS));
633 for row in self.store.stalled_updates(&before, 10).await? {
634 let Some(repo) = self.store.repo(&row.repo_id).await? else { continue };
635 if repo.upkeep == 0 || !self.active(&repo.repo_id).await? {
636 self.store
637 .set_update(&row, UpdateState::Failed, row.pull(), None, Some("The version could not be raised in a sandbox."))
638 .await?;
639 continue;
640 }
641 self.needs_code(&repo, &row, "The version could not be raised in a sandbox on its own").await?;
642 }
643 Ok(())
644 }
645
646 /// Raising the version is not enough: closes g1t's pull request, opens
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent647 /// an issue for the change, and puts g1t to work on it.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily648 async fn needs_code(&self, repo: &RepoRow, row: &UpdateRow, why: &str) -> Result<()> {
649 let path = Self::path_of(repo);
650 let system = User::system(&repo.namespace);
651 let open = self.store.open_vulnerabilities(&repo.repo_id).await?;
652 let vulns: Vec<&VulnRow> = open
653 .iter()
654 .filter(|vuln| vuln.ecosystem == row.ecosystem && vuln.package == row.package)
655 .collect();
656 if vulns.is_empty() {
657 return self.supersede(repo, row, format!("`{}` is no longer vulnerable here.", row.package)).await;
658 }
659 let issue: Outcome<g1t_contracts::work::Issue> = g1t_kit::call(
660 &self.work,
661 "open_issue",
662 &OpenIssueArgs {
663 actor: system.clone(),
664 repo: path.clone(),
665 title: format!("Upgrade {} to {}: needs code changes", row.package, row.target).chars().take(200).collect(),
666 body: issue_text(&row.ecosystem, &row.package, &row.target, &vulns, &[]),
667 labels: vec!["dependencies".to_owned(), "security".to_owned()],
668 checks: Vec::new(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar669 milestone: None,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily670 },
671 )
672 .await?;
673 let issue = match issue {
674 Outcome::Ok(issue) => issue,
675 Outcome::Fail(refused) => {
676 let error = format!("{why}, and the issue for it could not be opened: {}", refused.message);
677 self.store.set_update(row, UpdateState::Failed, row.pull(), None, Some(&error)).await?;
678 return self.note(row, "update_failed", Some(g1t_contracts::system::USERNAME), None, Some(&error)).await;
679 }
680 };
681 self.store
682 .set_update(row, UpdateState::NeedsCode, row.pull(), Some(issue.number), Some(why))
683 .await?;
684 if let Some(number) = row.pull() {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent685 self.close_with(repo, number, format!("{why}, so code has to change too. g1t is making the change in #{}.", issue.number))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily686 .await?;
687 }
688 let started: Outcome<Value> =
689 g1t_kit::call(&self.runner, "run", &json!({ "actor": system, "repo": path, "issue": issue.number })).await?;
690 if let Outcome::Fail(refused) = started {
691 self.comment(&system, &path, issue.number, format!(
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent692 "g1t could not put an agent on this upgrade: {}\n\nAssign it to g1t once agents can run here, or upgrade it by hand.",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily693 refused.message
694 ))
695 .await?;
696 }
697 self.note(row, "update_needs_code", Some(g1t_contracts::system::USERNAME), Some(issue.number), Some(why)).await
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar705 fn rules(extra: &str) -> Config {
706 let source = format!(
707 "version: 2\nupdates:\n - package-ecosystem: npm\n directories: [\"/\", \"/apps/*\"]\n schedule: {{interval: weekly}}\n{extra} - package-ecosystem: npm\n directory: /legacy\n target-branch: develop\n schedule: {{interval: weekly}}\n"
708 );
709 let found = crate::config::read(&source);
710 assert!(found.problems.is_empty(), "{:?}", found.problems);
711 found.config
712 }
713
714 #[test]
715 fn security_updates_follow_the_file() {
716 let config = rules(
717 " ignore:\n - dependency-name: left-pad\n - dependency-name: react\n versions: [\">=19\"]\n groups:\n fixes:\n applies-to: security-updates\n patterns: [\"@babel/*\"]\n update-types: [patch, minor]\n minor:\n patterns: [\"*\"]\n",
718 );
719 let entry = security_entry(&config, "main", "npm", &["apps/web/package-lock.json"]).unwrap();
720 assert_eq!(entry.id(), "npm:/,/apps/*");
721 // An entry for another branch never speaks for security updates.
722 assert!(security_entry(&config, "main", "npm", &["legacy/package-lock.json"]).is_none());
723 assert!(security_entry(&config, "main", "crates.io", &["Cargo.lock"]).is_none());
724 assert!(!security_allowed(entry, &[], "left-pad", "1.3.0"));
725 assert!(!security_allowed(entry, &[], "react", "19.0.1"));
726 assert!(security_allowed(entry, &[], "react", "18.3.1"));
727 let comment = IgnoreCondition { ecosystem: "npm".into(), dependency: "Lodash".into(), versions: None, update_type: None, by: "ana".into(), pull: None, at: String::new() };
728 assert!(!security_allowed(entry, &[comment], "lodash", "4.17.21"));
729 assert_eq!(security_group(entry, "@babel/core", "7.0.0", "7.24.1").as_deref(), Some("fixes"));
730 // A major bump is outside the group's update types, and only security groups count.
731 assert_eq!(security_group(entry, "@babel/core", "6.0.0", "7.24.1"), None);
732 assert_eq!(security_group(entry, "lodash", "4.17.20", "4.17.21"), None);
733 let named = rules(" allow:\n - dependency-name: \"lodash\"\n");
734 let entry = security_entry(&named, "main", "npm", &["package-lock.json"]).unwrap();
735 assert!(security_allowed(entry, &[], "lodash", "4.17.21") && !security_allowed(entry, &[], "minimist", "1.2.6"));
736 }
737
738 #[test]
739 fn grouped_security_updates_say_what_they_fix() {
740 let vuln = row("4.17.20", "4.17.21");
741 let members = vec![GroupMember { ecosystem: "npm".into(), package: "lodash".into(), from: "4.17.20".into(), target: "4.17.21".into(), manifests: vec!["package-lock.json".into()] }];
742 let body = group_text("fixes", &members, &[&vuln], ".github/dependabot.yml");
743 assert!(body.starts_with("Upgrades the fixes group's vulnerable packages"));
744 assert!(body.contains("| `lodash` | 4.17.20 | **4.17.21** |"));
745 assert!(body.contains("GHSA-35jh-r3h4-6jhm") && body.contains("`@g1t rebase`"));
746 assert!(body.ends_with("on its Security page._"));
747 assert_eq!(prefixed("build(deps): ", "Upgrade lodash to 4.17.21".into()), "build(deps): upgrade lodash to 4.17.21");
748 assert_eq!(prefixed("", "Upgrade lodash to 4.17.21".into()), "Upgrade lodash to 4.17.21");
749 }
750
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily751 fn row(version: &str, fixed: &str) -> VulnRow {
752 VulnRow {
753 id: "vul_1".into(),
754 repo_id: "rep_1".into(),
755 ecosystem: "npm".into(),
756 package: "lodash".into(),
757 version: version.into(),
758 manifest: "web/package-lock.json".into(),
759 osv_id: "GHSA-35jh-r3h4-6jhm".into(),
760 advisory: "GHSA-35jh-r3h4-6jhm".into(),
761 summary: "Command Injection in lodash".into(),
762 severity: "high".into(),
763 fixed_version: Some(fixed.into()),
764 status: "open".into(),
765 found_at: "2026-10-04T00:00:00Z".into(),
766 fixed_at: None,
767 number: None,
768 dismiss_reason: None,
769 dismiss_comment: None,
770 dismissed_by: None,
771 dismissed_at: None,
772 }
773 }
774
775 #[test]
776 fn a_newer_fix_asks_again_and_the_same_one_waits() {
777 let retry = "2026-10-05T00:00:00Z";
778 let at = "2026-10-06T00:00:00Z";
779 assert_eq!(next_step(UpdateState::Open, "4.17.20", "4.17.21", at, retry), Next::Request);
780 assert_eq!(next_step(UpdateState::Open, "4.17.21", "4.17.21", at, retry), Next::Wait);
781 assert_eq!(next_step(UpdateState::Requested, "4.17.21", "4.17.21", at, retry), Next::Wait);
782 assert_eq!(next_step(UpdateState::Merged, "4.17.21", "4.17.21", at, retry), Next::Wait);
783 // A person closed it: left alone until a newer fix.
784 assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.21", at, retry), Next::Wait);
785 assert_eq!(next_step(UpdateState::Closed, "4.17.21", "4.17.22", at, retry), Next::Request);
786 // Vulnerable again after it was no longer needed.
787 assert_eq!(next_step(UpdateState::Superseded, "4.17.21", "4.17.21", at, retry), Next::Request);
788 // Failed: tried again a day later.
789 assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", at, retry), Next::Wait);
790 assert_eq!(next_step(UpdateState::Failed, "4.17.21", "4.17.21", "2026-10-04T00:00:00Z", retry), Next::Request);
791 assert_eq!(next_step(UpdateState::NeedsCode, "4.17.21", "4.17.21", at, retry), Next::Wait);
792 }
793
794 #[test]
795 fn the_pull_request_says_what_it_fixes_and_where() {
796 let a = row("4.17.20", "4.17.21");
797 let mut b = row("4.17.19", "4.17.21");
798 b.manifest = "package-lock.json".into();
799 let body = pull_text("npm", "lodash", "4.17.21", &[&a, &b]);
800 assert!(body.starts_with("Upgrades `lodash` (npm) from 4.17.19, 4.17.20 to **4.17.21**"));
801 assert!(body.contains("[GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)"));
802 assert!(body.contains("Lockfiles changed: `package-lock.json`, `web/package-lock.json`."));
803 assert!(body.contains("required checks"));
804 assert_eq!(pull_title("lodash", "4.17.21"), "Upgrade lodash to 4.17.21");
805 assert_eq!(update_branch("@babel/core", "7.24.1"), "g1t/security/babel-core-7.24.1");
806 assert_eq!(update_branch("golang.org/x/net", "v0.23.0"), "g1t/security/golang.org-x-net-v0.23.0");
807 }
808}

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