Skip to content
481 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-a6a121745e81f639f'1//! The RubyGems registry: `g1t.sh/-/rubygems/<workspace>/`, one for each
2//! workspace. `gem push --host` sends a g1t token as the whole
3//! `Authorization` header (`GEM_HOST_API_KEY`, or `~/.gem/credentials`);
4//! Bundler sends Basic credentials (any username, a g1t token as the
5//! password) from `bundle config`.
6//!
7//! Bundler installs from the compact index (`versions`, `info/<gem>`,
8//! `names`), made from the versions on each read, with each file's MD5 as
Merge branch 'worktree-agent-ac1de8a731938ed81'9//! its `ETag` as Bundler checks it. `gem install --source` and `gem search`
10//! read the full index: `specs.4.8.gz` (and `latest_` and `prerelease_`),
11//! and a version's `quick/Marshal.4.8/<gem>.gemspec.rz`, made from what
12//! each version keeps, in Ruby's Marshal format. A `.gem` is stored once, by its
Merge branch 'worktree-agent-a6a121745e81f639f'13//! SHA-256, which is also its index `checksum`. `gem yank` takes a version
14//! out of the index; its file stays for lockfiles that name it.
15
16use g1t_contracts::User;
17use g1t_contracts::events::PackageEvent;
18use g1t_contracts::new_id;
19use g1t_kit::now_ms;
20use serde_json::Value;
21use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result, Url};
22
Merge packages: roles, Actions access, source label, soft delete, API23use crate::access::Action;
Merge branch 'worktree-agent-a6a121745e81f639f'24use crate::db::{NewFile, NewVersion, PackageRow, VersionRow};
25use crate::digest::Digest;
26use crate::oci::{Credentials, published_by};
27use crate::rubygems::{self, GemRoute};
28use crate::store::BlobStore;
29use crate::{Caller, Packages, TargetOf, cargo, npm, token};
30
31const RUBYGEMS: &str = "rubygems";
32/// The most versions a workspace's index lists.
33const MAX_VERSIONS: u32 = 20_000;
34/// The most gems a workspace's index lists.
35const MAX_GEMS: u32 = 2000;
36const DOCS: &str = "https://docs.g1t.sh/guides/rubygems/";
37const TOKENS: &str = "https://g1t.sh/settings/tokens";
38
39/// A plain-text answer, which `gem` and Bundler print.
40fn error(status: u16, message: impl Into<String>) -> Result<Response> {
41 let mut response = Response::ok(message.into())?.with_status(status);
42 response.headers_mut().set("content-type", "text/plain; charset=utf-8")?;
43 if status == 401 {
44 response.headers_mut().set("www-authenticate", "Basic realm=\"g1t\"")?;
45 }
46 Ok(response)
47}
48
49fn sign_in(workspace: &str) -> String {
50 format!(
51 "Sign in to use this registry: bundle config set --global https://g1t.sh/-/rubygems/{workspace}/ <you>:<token>, with a g1t access token from {TOKENS}. See {DOCS}"
52 )
53}
54
55/// An index file, with the quoted MD5 of its body as its `ETag`: Bundler
56/// checks the file it keeps against it, and asks with `If-None-Match`.
57fn index_file(request: &Request, body: String, head: bool) -> Result<Response> {
58 let etag = format!("\"{:x}\"", md5::compute(body.as_bytes()));
59 let headers = Headers::new();
60 headers.set("content-type", "text/plain; charset=utf-8")?;
61 headers.set("cache-control", "no-cache")?;
62 headers.set("etag", &etag)?;
63 if request.headers().get("if-none-match")?.is_some_and(|sent| sent.trim_start_matches("W/") == etag) {
64 return Ok(Response::empty()?.with_status(304).with_headers(headers));
65 }
66 headers.set("content-length", &body.len().to_string())?;
67 let body = if head { ResponseBody::Empty } else { ResponseBody::Body(body.into_bytes()) };
68 Ok(Response::from_body(body)?.with_headers(headers))
69}
70
Merge branch 'worktree-agent-ac1de8a731938ed81'71/// A full index file or a specification, which `gem` reads as bytes.
72fn binary(bytes: Vec<u8>, head: bool, cache: &str) -> Result<Response> {
73 let headers = Headers::new();
74 headers.set("content-type", "application/octet-stream")?;
75 headers.set("content-length", &bytes.len().to_string())?;
76 headers.set("cache-control", cache)?;
77 let body = if head { ResponseBody::Empty } else { ResponseBody::Body(bytes) };
78 Ok(Response::from_body(body)?.with_headers(headers))
79}
80
Merge branch 'worktree-agent-a6a121745e81f639f'81/// A version's line in the index.
82fn line(row: &VersionRow) -> String {
83 let checksum = Digest::parse(&row.digest).map(|d| d.hex().to_owned()).unwrap_or_default();
84 rubygems::info_line(&row.version, &row.meta(), &checksum)
85}
86
87impl Packages {
88 /// Answers a RubyGems request.
89 pub async fn rubygems(&self, request: Request, ctx: &Context) -> Result<Response> {
90 let url = request.url()?;
91 let Some((workspace, route)) = rubygems::route(url.path()) else {
92 return error(404, "There is nothing at this address.");
93 };
94 match self.rubygems_route(request, &url, &workspace, route, ctx).await {
95 Ok(response) => Ok(response),
96 Err(problem) => {
97 worker::console_error!("packages: rubygems {}: {problem}", url.path());
98 error(500, "Something went wrong on our side. Try again in a moment.")
99 }
100 }
101 }
102
103 /// Who the request is from: `gem`'s bare key (or a `Bearer` one), or
104 /// Bundler's Basic credentials.
105 async fn gem_credentials(&self, request: &Request) -> Result<Credentials> {
106 let Some(header) = request.headers().get("authorization")? else {
107 return Ok(Credentials::None);
108 };
109 let viewer = if let Some((username, secret)) = token::basic(&header) {
110 self.viewer_for(&username, &secret).await?
111 } else if let Some(key) = cargo::token(&header) {
112 self.viewer_for("token", key).await?
113 } else {
114 None
115 };
116 Ok(match viewer {
117 Some(user) => Credentials::Viewer(Some(user)),
118 None => Credentials::Bad,
119 })
120 }
121
122 async fn rubygems_route(&self, mut request: Request, url: &Url, workspace: &str, route: GemRoute, ctx: &Context) -> Result<Response> {
123 let method = request.method();
124 let credentials = self.gem_credentials(&request).await?;
125 let read = matches!(method, Method::Get | Method::Head);
126 if read && let Some(refused) = self.limited(&request, &credentials, "Basic credentials in bundle config").await? {
127 return Ok(refused);
128 }
129 let viewer = match credentials {
130 Credentials::Viewer(viewer) => viewer,
131 Credentials::None => None,
132 Credentials::Token(_) | Credentials::Bad => {
133 return error(401, format!("The token is not right, or has expired. Make an access token at {TOKENS}."));
134 }
135 };
136 let viewer = viewer.as_ref();
137 let head = method == Method::Head;
138 match route {
139 GemRoute::Versions if read => self.gem_versions(&request, workspace, viewer, head).await,
140 GemRoute::Names if read => self.gem_names(&request, workspace, viewer, head).await,
141 GemRoute::Info { name } if read => self.gem_info(&request, workspace, &name, viewer, head).await,
142 GemRoute::Gem { stem } if read => self.gem_download(workspace, &stem, viewer, head, ctx).await,
Merge branch 'worktree-agent-ac1de8a731938ed81'143 GemRoute::Specs(which) if read => self.gem_specs(workspace, which, viewer, head).await,
144 GemRoute::QuickSpec { stem } if read => self.gem_quick_spec(workspace, &stem, viewer, head).await,
Merge branch 'worktree-agent-a6a121745e81f639f'145 GemRoute::Push if method == Method::Post => self.gem_push(&mut request, workspace, viewer).await,
146 GemRoute::Yank if method == Method::Delete => self.gem_yank(&mut request, url, workspace, viewer).await,
147 _ => error(405, "Not a method this address takes."),
148 }
149 }
150
151 /// The answer for something not there: a `401` to someone not signed
152 /// in when the workspace has private gems, so Bundler asks for
153 /// credentials and a private gem looks like a missing one.
154 async fn gem_absent(&self, workspace: &str, viewer: Option<&User>) -> Result<Response> {
155 if viewer.is_none() && self.db.has_private(workspace, RUBYGEMS).await? {
156 return error(401, sign_in(workspace));
157 }
158 error(404, "Not found: no such gem or version, or you cannot see it.")
159 }
160
161 async fn gem_check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Result<Option<Response>> {
Merge packages: roles, Actions access, source label, soft delete, API162 let (decision, readable) = self.check(viewer, package, action).await?;
Merge branch 'worktree-agent-a6a121745e81f639f'163 if decision.allowed {
164 return Ok(None);
165 }
166 if !readable && viewer.is_none() {
167 return Ok(Some(error(401, sign_in(&package.workspace))?));
168 }
169 if !readable {
170 return Ok(Some(self.gem_absent(&package.workspace, viewer).await?));
171 }
172 Ok(Some(error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()))?))
173 }
174
175 /// The workspace's gems the viewer may see, with their versions in the
176 /// index (not yanked), oldest first.
177 async fn gem_index(&self, workspace: &str, viewer: Option<&User>) -> Result<std::result::Result<Vec<(PackageRow, Vec<VersionRow>)>, Response>> {
178 if viewer.is_none() && self.db.has_private(workspace, RUBYGEMS).await? {
179 return Ok(Err(error(401, sign_in(workspace))?));
180 }
181 let packages = self.db.packages_of(workspace, RUBYGEMS, MAX_GEMS).await?;
182 let versions = self.db.ecosystem_versions(workspace, RUBYGEMS, MAX_VERSIONS).await?;
Merge packages: roles, Actions access, source label, soft delete, API183 let may = self.may_all(viewer, &packages.iter().collect::<Vec<_>>(), Action::Pull).await?;
Merge branch 'worktree-agent-a6a121745e81f639f'184 Ok(Ok(packages
185 .into_iter()
Merge packages: roles, Actions access, source label, soft delete, API186 .zip(may)
187 .filter(|(_, may)| *may)
188 .map(|(p, _)| {
Merge branch 'worktree-agent-a6a121745e81f639f'189 let rows: Vec<VersionRow> = versions.iter().filter(|v| v.package_id == p.id && !v.is_yanked()).cloned().collect();
190 (p, rows)
191 })
192 .filter(|(_, rows)| !rows.is_empty())
193 .collect()))
194 }
195
196 async fn gem_versions(&self, request: &Request, workspace: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
197 let gems = match self.gem_index(workspace, viewer).await? {
198 Ok(gems) => gems,
199 Err(refused) => return Ok(refused),
200 };
201 let created = gems
202 .iter()
203 .flat_map(|(_, rows)| rows.iter().map(|r| r.published_at.as_str()))
204 .min()
205 .map(|at| format!("{}Z", &at[..at.len().min(19)]))
206 .unwrap_or_else(|| "2026-01-01T00:00:00Z".to_owned());
207 let lines: Vec<(String, Vec<String>, String)> = gems
208 .iter()
209 .map(|(package, rows)| {
210 let info = rubygems::info(&rows.iter().map(line).collect::<Vec<_>>());
211 (package.name.clone(), rows.iter().map(|r| r.version.clone()).collect(), format!("{:x}", md5::compute(info.as_bytes())))
212 })
213 .collect();
214 index_file(request, rubygems::versions_file(&created, &lines), head)
215 }
216
217 async fn gem_names(&self, request: &Request, workspace: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
218 let gems = match self.gem_index(workspace, viewer).await? {
219 Ok(gems) => gems,
220 Err(refused) => return Ok(refused),
221 };
222 let names: Vec<String> = gems.into_iter().map(|(p, _)| p.name).collect();
223 index_file(request, rubygems::names_file(&names), head)
224 }
225
226 async fn gem_info(&self, request: &Request, workspace: &str, name: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
227 let Some(package) = self.db.package(workspace, RUBYGEMS, name).await?.filter(|p| !p.hidden()) else {
228 return self.gem_absent(workspace, viewer).await;
229 };
230 if let Some(refusal) = self.gem_check(viewer, &package, Action::Pull).await? {
231 return Ok(refusal);
232 }
233 let mut rows: Vec<VersionRow> = self.db.versions(&package.id, MAX_VERSIONS).await?.into_iter().filter(|v| !v.is_yanked()).collect();
234 if rows.is_empty() {
235 return self.gem_absent(workspace, viewer).await;
236 }
237 rows.reverse();
238 index_file(request, rubygems::info(&rows.iter().map(line).collect::<Vec<_>>()), head)
239 }
240
Merge branch 'worktree-agent-ac1de8a731938ed81'241 /// A full index file: the versions in the index of every gem the
242 /// viewer may see, as `[name, Gem::Version, platform]`.
243 async fn gem_specs(&self, workspace: &str, which: rubygems::Specs, viewer: Option<&User>, head: bool) -> Result<Response> {
244 let gems = match self.gem_index(workspace, viewer).await? {
245 Ok(gems) => gems,
246 Err(refused) => return Ok(refused),
247 };
248 let tuples: Vec<rubygems::Tuple> =
249 gems.iter().flat_map(|(package, rows)| rows.iter().map(|row| rubygems::Tuple::of(&package.name, &row.version, &row.meta()))).collect();
250 binary(rubygems::specs_file(which, &tuples), head, "no-cache")
251 }
252
253 /// A version's specification, marshalled and deflated, which `gem
254 /// install` reads before the gem. Yanked versions' too, as their files.
255 async fn gem_quick_spec(&self, workspace: &str, stem: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
256 for (name, key) in rubygems::candidates(stem).into_iter().rev() {
257 let Some(package) = self.db.package(workspace, RUBYGEMS, &name).await?.filter(|p| !p.hidden()) else {
258 continue;
259 };
260 if let Some(refusal) = self.gem_check(viewer, &package, Action::Pull).await? {
261 return Ok(refusal);
262 }
263 let Some(row) = self.db.version_named(&package.id, &key).await? else {
264 continue;
265 };
266 let spec = rubygems::quick_spec(&package.name, &row.version, &row.meta(), &row.published_at);
267 return binary(spec, head, "max-age=300");
268 }
269 self.gem_absent(workspace, viewer).await
270 }
271
Merge branch 'worktree-agent-a6a121745e81f639f'272 /// A `.gem`, yanked ones too: a lockfile may still name them.
273 async fn gem_download(&self, workspace: &str, stem: &str, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> {
274 for (name, key) in rubygems::candidates(stem).into_iter().rev() {
275 let Some(package) = self.db.package(workspace, RUBYGEMS, &name).await?.filter(|p| !p.hidden()) else {
276 continue;
277 };
278 if let Some(refusal) = self.gem_check(viewer, &package, Action::Pull).await? {
279 return Ok(refusal);
280 }
281 let Some(row) = self.db.version_named(&package.id, &key).await? else {
282 continue;
283 };
284 let Some(digest) = Digest::parse(&row.digest) else { continue };
285 let Some(blob) = self.db.package_blob(&package.id, &digest).await? else { continue };
286 let headers = Headers::new();
287 headers.set("content-type", "application/octet-stream")?;
288 headers.set("content-length", &blob.size.to_string())?;
289 headers.set("cache-control", "max-age=31536000")?;
290 if head {
291 return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
292 }
293 let Some(got) = self.store.get(&blob.object_key, None).await? else { continue };
Merge packages: roles, Actions access, source label, soft delete, API294 self.count_version_download(&package.id, &row.id, ctx);
Merge branch 'worktree-agent-a6a121745e81f639f'295 return Ok(Response::from_body(got.body)?.with_headers(headers));
296 }
297 self.gem_absent(workspace, viewer).await
298 }
299
300 /// The package a first push makes, linked to the repository the gem's
301 /// `source_code_uri` or homepage names on g1t, or else one named like it.
302 async fn gem_target(&self, workspace: &str, spec: &rubygems::Gemspec) -> Result<TargetOf> {
303 let named: Vec<String> = [&spec.source_code_uri, &spec.homepage]
304 .into_iter()
305 .flatten()
306 .filter_map(|url| npm::repository_of(&Value::String(url.clone()), &self.host))
307 .filter(|(owner, _)| owner == workspace)
308 .map(|(_, repo)| repo)
309 .collect();
310 let lower = spec.name.to_ascii_lowercase();
311 let dashed = lower.replace('_', "-");
312 let mut repo = None;
313 for candidate in named.iter().map(String::as_str).chain([lower.as_str(), dashed.as_str()]) {
314 if let Some(found) = self.repo_by_name(workspace, candidate).await? {
315 repo = Some(found);
316 break;
317 }
318 }
Merge packages: roles, Actions access, source label, soft delete, API319 Ok(TargetOf::unmade(workspace, &spec.name, repo.map(|r| (r.id, r.name, r.is_private))))
Merge branch 'worktree-agent-a6a121745e81f639f'320 }
321
322 /// `gem push`: the `.gem` as the body.
323 async fn gem_push(&self, request: &mut Request, workspace: &str, viewer: Option<&User>) -> Result<Response> {
324 let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
325 let too_large = || {
326 let mb = self.max_request / 1_000_000;
327 error(413, format!("A gem may be at most {mb} MB. See {DOCS}#size"))
328 };
329 if declared.is_some_and(|n| n > self.max_request) {
330 return too_large();
331 }
332 let gem = request.bytes().await?;
333 if gem.len() as u64 > self.max_request {
334 return too_large();
335 }
336 if viewer.is_none() {
337 return error(401, format!("Push with a g1t access token: GEM_HOST_API_KEY=<token> gem push <file> --host https://g1t.sh/-/rubygems/{workspace}. Make one at {TOKENS}."));
338 }
339 let spec = match rubygems::read_gem(&gem) {
340 Ok(spec) => spec,
341 Err(message) => return error(422, message),
342 };
343 if !rubygems::valid_name(&spec.name) {
344 return error(422, format!("{} is not a valid gem name: letters, digits, ., - and _, with a letter.", spec.name));
345 }
346 if !rubygems::valid_version(&spec.version) {
347 return error(422, format!("{} is not a version RubyGems reads.", spec.version));
348 }
349 let key = rubygems::key(&spec.version, &spec.platform);
350
351 // A name is taken whatever its case.
352 let found = self.db.package_any_case(workspace, RUBYGEMS, &spec.name).await?;
353 if let Some(found) = &found {
354 if found.hidden() {
Merge packages: roles, Actions access, source label, soft delete, API355 return error(403, Packages::hidden_refusal(found));
Merge branch 'worktree-agent-a6a121745e81f639f'356 }
357 if found.name != spec.name {
358 return error(409, format!("The name {} is taken by the gem {}. Push it under that name.", spec.name, found.name));
359 }
360 } else if self.db.workspace_hidden(workspace).await? {
361 return error(403, format!("The workspace {workspace} is deleted; nothing can be pushed to it."));
362 }
Merge packages: roles, Actions access, source label, soft delete, API363 let mut target = match &found {
Merge branch 'worktree-agent-a6a121745e81f639f'364 Some(package) => TargetOf::package(package),
365 None => self.gem_target(workspace, &spec).await?,
366 };
Merge packages: roles, Actions access, source label, soft delete, API367 let decision = self.decide(viewer, &mut target, Action::Push).await?;
Merge branch 'worktree-agent-a6a121745e81f639f'368 if !decision.allowed {
Merge packages: roles, Actions access, source label, soft delete, API369 let readable = found.is_none() || self.decide(viewer, &mut target, Action::Pull).await?.allowed;
Merge branch 'worktree-agent-a6a121745e81f639f'370 if !readable {
371 return error(404, "Not found: no such gem, or you cannot see it.");
372 }
373 return error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()));
374 }
Merge packages: roles, Actions access, source label, soft delete, API375 let caller = Caller::of(viewer);
Merge branch 'worktree-agent-a6a121745e81f639f'376 let package = match found {
377 Some(package) => package,
378 None => {
Merge packages: roles, Actions access, source label, soft delete, API379 self.make_package(
380 workspace,
381 RUBYGEMS,
382 &spec.name,
383 target.repo.as_ref().map(|(id, repo, private)| (id.as_str(), repo.as_str(), *private)),
384 &caller,
385 now_ms(),
386 )
387 .await?
Merge branch 'worktree-agent-a6a121745e81f639f'388 }
389 };
390 let existing = self.db.versions(&package.id, MAX_VERSIONS).await?;
391 if existing.iter().any(|v| v.version == key) {
392 return error(409, format!("{} ({key}) is already pushed, and a version is pushed once, yanked or not. Bump the version.", package.name));
393 }
Merge packages: roles, Actions access, source label, soft delete, API394 if let Some(refused) = self.reserved_refusal(&package, &key).await? {
395 return error(409, refused);
396 }
Merge branch 'worktree-agent-a6a121745e81f639f'397
398 let digest = Digest::of(&gem);
399 let size = gem.len() as u64;
400 if let Some(refusal) = self.storage_refusal(&package, &[(digest.to_string(), size)]).await? {
401 return error(403, refusal);
402 }
403 let now = now_ms();
404 let stored = match self.db.blob(&digest).await? {
405 Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
406 None => false,
407 };
408 if !stored {
409 self.store.put(&digest.object_key(), gem).await?;
410 }
411 self.db.keep_blob(&package.id, &digest, size, Some("application/octet-stream"), &digest.object_key(), now).await?;
412 self.db
413 .publish(
414 NewVersion {
415 id: new_id("ver", now),
416 package_id: package.id.clone(),
417 version: key.clone(),
418 digest: digest.to_string(),
419 size,
420 metadata: rubygems::stored(&spec).to_string(),
421 subject: None,
422 published_by: published_by(&caller),
423 files: vec![NewFile { name: "gem".to_owned(), digest: digest.to_string(), size, media_type: Some("application/octet-stream".to_owned()) }],
424 },
425 None,
426 now,
427 )
428 .await?;
429 // The description the page shows: the highest stable version's.
430 let highest = !rubygems::is_prerelease(&spec.version)
431 && existing.iter().map(|v| v.meta()).filter_map(|m| m["number"].as_str().map(str::to_owned)).all(|other| {
432 rubygems::is_prerelease(&other) || crate::db::newest_version(&format!("{other}\n{}", spec.version)).as_deref() == Some(spec.version.as_str())
433 });
434 if highest || existing.is_empty() {
435 let description = spec.summary.as_deref().or(spec.description.as_deref());
436 self.db.set_readme(&package.id, None, description, now).await?;
437 }
438 self.db.measure(&package.workspace).await?;
439 let event = PackageEvent {
440 version: Some(key.clone()),
441 digest: Some(digest.to_string()),
442 size: Some(size),
443 ..self.event_of(&package)
444 };
445 self.announce("package.published", &package, event, &caller).await;
446 self.audit(&caller, "package.publish", &package, Some(&format!("{workspace}/{}@{key}", package.name)), None).await;
447 let mut response = Response::ok(format!("Successfully registered gem: {} ({key})", package.name))?;
448 response.headers_mut().set("content-type", "text/plain; charset=utf-8")?;
449 Ok(response)
450 }
451
452 /// `gem yank`: the version leaves the index, its file stays.
453 async fn gem_yank(&self, request: &mut Request, url: &Url, workspace: &str, viewer: Option<&User>) -> Result<Response> {
454 let body = request.text().await.unwrap_or_default();
455 let query = url.query().unwrap_or("").to_owned();
456 let value = |key: &str| rubygems::form_value(&body, key).or_else(|| rubygems::form_value(&query, key));
457 let (Some(name), Some(version)) = (value("gem_name"), value("version")) else {
458 return error(400, "Name the gem and version: gem yank <gem> --version <version>.");
459 };
460 let key = rubygems::key(&version, value("platform").as_deref().unwrap_or("ruby"));
461 let Some(package) = self.db.package(workspace, RUBYGEMS, &name).await?.filter(|p| !p.hidden()) else {
462 return self.gem_absent(workspace, viewer).await;
463 };
464 if let Some(refusal) = self.gem_check(viewer, &package, Action::Push).await? {
465 return Ok(refusal);
466 }
467 let Some(row) = self.db.version_named(&package.id, &key).await? else {
468 return error(404, format!("{name} ({key}) is not there."));
469 };
470 if row.is_yanked() {
471 return error(422, format!("{name} ({key}) is already yanked."));
472 }
473 self.db.set_yanked(&row.id, true).await?;
474 self.db.touch_package(&package.id, now_ms()).await?;
Merge packages: roles, Actions access, source label, soft delete, API475 let caller = Caller::of(viewer);
Merge branch 'worktree-agent-a6a121745e81f639f'476 self.audit(&caller, "package.yank", &package, Some(&format!("{workspace}/{name}@{key}")), None).await;
477 let mut response = Response::ok(format!("Successfully deleted gem: {name} ({key})"))?;
478 response.headers_mut().set("content-type", "text/plain; charset=utf-8")?;
479 Ok(response)
480 }
481}

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