Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
528 linesCodeBlameRaw
1//! Artifacts of workflow runs: which there are, kept here, while the API
2//! keeps their bytes in R2 (the ACTIONS_CACHE bucket, under `a/`).
3//!
4//! - A name is one artifact in a run. Uploading a name the run has is
5//! refused unless the upload says `overwrite`, which replaces it.
6//! - One artifact is at most `ARTIFACT_MAX_BYTES`, and a run's artifacts at
7//! most `RUN_ARTIFACTS_MAX_BYTES` together.
8//! - Each is kept for its `retention-days`, or the repository's setting
9//! when it gives none, and never longer than that setting (1 to 90 days,
10//! 14 unless changed). The hourly sweep deletes what has expired, and
11//! uploads left unfinished.
12//! - Ids are numbers, as GitHub's are.
13//!
14//! A sandbox reaches these with its job's token or its runtime token
15//! (runtime.rs); people through the API and the run's page.
16
17use g1t_contracts::access::Capability;
18use g1t_contracts::actions::{
19 ARTIFACT_MAX_BYTES, ARTIFACT_RETENTION_DEFAULT_DAYS, ARTIFACT_RETENTION_MAX_DAYS, Artifact, ArtifactArgs, ArtifactBlob, ArtifactCommitArgs,
20 ArtifactList, ArtifactReservation, ArtifactReserveArgs, ArtifactRetention, ArtifactRetentionArgs, ArtifactsArgs, DeleteArtifactArgs,
21 JobArtifactsArgs, RUN_ARTIFACTS_MAX_BYTES,
22};
23use g1t_contracts::time::{parse_rfc3339, rfc3339};
24use g1t_contracts::{FailureCode, Outcome, new_id};
25use g1t_kit::now_ms;
26use serde::Deserialize;
27use worker::Result;
28use worker::wasm_bindgen::JsValue;
29
30use crate::runtime::{DOWNLOAD_SECONDS, LINK_SECONDS};
31use crate::{Actions, check, fail};
32
33const DAY_MS: u64 = 24 * 60 * 60 * 1000;
34/// An upload not finished after this long is given up.
35const PENDING_MS: u64 = 6 * 60 * 60 * 1000;
36const COLUMNS: &str = "artifacts.*, runs.git_ref AS git_ref, runs.sha AS sha";
37
38#[derive(Debug, Deserialize)]
39struct Row {
40 id: f64,
41 repo_id: String,
42 run_id: String,
43 job_id: String,
44 name: String,
45 object: String,
46 format: String,
47 size: f64,
48 digest: Option<String>,
49 status: String,
50 created_at: String,
51 updated_at: String,
52 expires_at: String,
53 #[serde(default)]
54 git_ref: Option<String>,
55 #[serde(default)]
56 sha: Option<String>,
57}
58
59impl Row {
60 fn view(&self) -> Artifact {
61 Artifact {
62 id: self.id as u64,
63 name: self.name.clone(),
64 size: self.size as u64,
65 digest: self.digest.clone(),
66 format: self.format.clone(),
67 run_id: self.run_id.clone(),
68 job_id: self.job_id.clone(),
69 repo_id: self.repo_id.clone(),
70 expired: self.status == "expired",
71 created_at: self.created_at.clone(),
72 updated_at: self.updated_at.clone(),
73 expires_at: self.expires_at.clone(),
74 head_branch: self.git_ref.as_deref().map(|r| r.strip_prefix("refs/heads/").unwrap_or(r).to_owned()),
75 head_sha: self.sha.clone(),
76 }
77 }
78}
79
80/// GitHub's rule for an artifact's name: 1 to 256 characters, none of
81/// `" : < > | * ? \ /` or line breaks.
82pub(crate) fn valid_name(name: &str) -> bool {
83 !name.trim().is_empty() && name.chars().count() <= 256 && !name.chars().any(|c| matches!(c, '"' | ':' | '<' | '>' | '|' | '*' | '?' | '\\' | '/' | '\r' | '\n'))
84}
85
86/// The days an artifact is kept: what it asked for (as days, or as a time
87/// to expire), else the repository's `setting`, and never more than the
88/// setting.
89pub(crate) fn retention(asked_days: u32, asked_until_ms: Option<u64>, setting: u32, now: u64) -> u32 {
90 let setting = setting.clamp(1, ARTIFACT_RETENTION_MAX_DAYS);
91 let days = match asked_until_ms {
92 _ if asked_days > 0 => asked_days,
93 Some(until) if until > now => (until - now).div_ceil(DAY_MS) as u32,
94 _ => setting,
95 };
96 days.clamp(1, setting)
97}
98
99/// Where an artifact is in R2: under its repository, by its id. The API's
100/// blobs.rs names it the same way.
101pub(crate) fn object_of(repo_id: &str, id: u64) -> String {
102 format!("a/{repo_id}/{id}")
103}
104
105fn number(id: u64) -> JsValue {
106 JsValue::from_f64(id as f64)
107}
108
109impl Actions {
110 /// How long the repository keeps artifacts.
111 pub(crate) async fn retention_setting(&self, repo_id: &str) -> Result<u32> {
112 #[derive(Deserialize)]
113 struct Setting {
114 artifact_retention_days: f64,
115 }
116 let setting = self
117 .db
118 .prepare("SELECT artifact_retention_days FROM repo_settings WHERE repo_id = ?")
119 .bind(&[repo_id.into()])?
120 .first::<Setting>(None)
121 .await?;
122 Ok(setting.map_or(ARTIFACT_RETENTION_DEFAULT_DAYS, |s| s.artifact_retention_days as u32))
123 }
124
125 async fn artifact_rows(&self, filter: &str, values: &[JsValue]) -> Result<Vec<Row>> {
126 self.db
127 .prepare(format!("SELECT {COLUMNS} FROM artifacts JOIN runs ON runs.id = artifacts.run_id WHERE {filter}"))
128 .bind(values)?
129 .all()
130 .await?
131 .results::<Row>()
132 }
133
134 /// The bytes a run's artifacts hold, `except` one.
135 async fn run_bytes(&self, run: &str, except: u64) -> Result<u64> {
136 #[derive(Deserialize)]
137 struct Sum {
138 bytes: Option<f64>,
139 }
140 let sum = self
141 .db
142 .prepare("SELECT SUM(size) AS bytes FROM artifacts WHERE run_id = ? AND status <> 'expired' AND id <> ?")
143 .bind(&[run.into(), number(except)])?
144 .first::<Sum>(None)
145 .await?;
146 Ok(sum.and_then(|s| s.bytes).unwrap_or(0.0) as u64)
147 }
148
149 /// Deletes artifacts' objects, then their rows. Objects that cannot be
150 /// deleted now are left to the sweep, their rows marked expired.
151 async fn forget_artifacts(&self, rows: &[(u64, String)]) -> Result<()> {
152 for (id, _) in rows {
153 self.db
154 .prepare("UPDATE artifacts SET status = 'expired', updated_at = ? WHERE id = ?")
155 .bind(&[rfc3339(now_ms()).into(), number(*id)])?
156 .run()
157 .await?;
158 }
159 let Some(bucket) = &self.cache else { return Ok(()) };
160 for chunk in rows.chunks(100) {
161 if let Err(error) = bucket.delete_multiple(chunk.iter().map(|(_, object)| object.as_str()).collect()).await {
162 worker::console_error!("actions: artifact objects not deleted: {error}");
163 return Ok(());
164 }
165 for (id, _) in chunk {
166 self.db.prepare("DELETE FROM artifacts WHERE id = ?").bind(&[number(*id)])?.run().await?;
167 }
168 }
169 Ok(())
170 }
171
172 /// `artifact_reserve`.
173 pub async fn artifact_reserve(&self, a: ArtifactReserveArgs) -> Result<Outcome<ArtifactReservation>> {
174 let job = check!(self.job_for_credential(&a.job, &a.token).await?);
175 if !valid_name(&a.name) {
176 return Ok(fail(
177 FailureCode::Invalid,
178 format!("`{}` is not an artifact name: 1 to 256 characters, none of \" : < > | * ? \\ / or line breaks.", a.name),
179 ));
180 }
181 if a.size > ARTIFACT_MAX_BYTES {
182 return Ok(fail(
183 FailureCode::Invalid,
184 format!("It is {} MB; an artifact is at most {} MB.", a.size / 1_048_576, ARTIFACT_MAX_BYTES / 1_048_576),
185 ));
186 }
187 let held = self.run_bytes(&job.run_id, 0).await?;
188 if held + a.size > RUN_ARTIFACTS_MAX_BYTES {
189 return Ok(fail(
190 FailureCode::Invalid,
191 format!("A run's artifacts are at most {} MB together, and this run's hold {} MB.", RUN_ARTIFACTS_MAX_BYTES / 1_048_576, held / 1_048_576),
192 ));
193 }
194 if let Some(why) = self.storage_refused(&job.namespace, a.size).await? {
195 return Ok(fail(FailureCode::Limit, why));
196 }
197 let existing = self
198 .artifact_rows("artifacts.run_id = ? AND artifacts.name = ? AND artifacts.status <> 'expired'", &[job.run_id.as_str().into(), a.name.as_str().into()])
199 .await?;
200 if !existing.is_empty() {
201 if !a.overwrite {
202 return Ok(fail(
203 FailureCode::Conflict,
204 format!("This run already has an artifact named {}. Give the upload another name, or `overwrite: true` to replace it.", a.name),
205 ));
206 }
207 let replaced: Vec<(u64, String)> = existing.iter().map(|row| (row.id as u64, row.object.clone())).collect();
208 self.forget_artifacts(&replaced).await?;
209 }
210 let now = now_ms();
211 let setting = self.retention_setting(&job.repo_id).await?;
212 let days = retention(a.retention_days, a.expires_at.as_deref().and_then(parse_rfc3339), setting, now);
213 let expires_at = rfc3339(now + u64::from(days) * DAY_MS);
214 let format = match a.format.as_deref() {
215 Some("tgz") => "tgz",
216 _ => "zip",
217 };
218 // Named by its id once it has one (`object_of` in the API's blobs.rs);
219 // until then, by a placeholder no other row has.
220 let placeholder = format!("a/{}/{}", job.repo_id, new_id("art", now));
221 let at = rfc3339(now);
222 #[derive(Deserialize)]
223 struct Inserted {
224 id: f64,
225 }
226 let inserted = self
227 .db
228 .prepare(
229 "INSERT INTO artifacts (repo_id, namespace, run_id, job_id, name, object, format, size, status, created_at, updated_at, expires_at)
230 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?9, ?10)
231 ON CONFLICT DO NOTHING
232 RETURNING id",
233 )
234 .bind(&[
235 job.repo_id.as_str().into(),
236 job.namespace.as_str().into(),
237 job.run_id.as_str().into(),
238 job.id.as_str().into(),
239 a.name.as_str().into(),
240 placeholder.as_str().into(),
241 format.into(),
242 (a.size as f64).into(),
243 at.as_str().into(),
244 expires_at.as_str().into(),
245 ])?
246 .first::<Inserted>(None)
247 .await?;
248 let Some(inserted) = inserted else {
249 return Ok(fail(FailureCode::Conflict, format!("Another job of this run is uploading an artifact named {}.", a.name)));
250 };
251 let id = inserted.id as u64;
252 let object = object_of(&job.repo_id, id);
253 self.db.prepare("UPDATE artifacts SET object = ? WHERE id = ?").bind(&[object.as_str().into(), number(id)])?.run().await?;
254 Ok(Outcome::Ok(ArtifactReservation { id, object, retention_days: days, expires_at }))
255 }
256
257 /// The pending artifact of a job's run named by id or name.
258 async fn pending_artifact(&self, run: &str, id: Option<u64>, name: Option<&str>) -> Result<Option<Row>> {
259 let rows = match (id, name) {
260 (Some(id), _) => self.artifact_rows("artifacts.id = ? AND artifacts.run_id = ? AND artifacts.status = 'pending'", &[number(id), run.into()]).await?,
261 (None, Some(name)) => {
262 self.artifact_rows("artifacts.name = ? AND artifacts.run_id = ? AND artifacts.status = 'pending'", &[name.into(), run.into()]).await?
263 }
264 (None, None) => Vec::new(),
265 };
266 Ok(rows.into_iter().next())
267 }
268
269 /// `artifact_commit`.
270 pub async fn artifact_commit(&self, a: ArtifactCommitArgs) -> Result<Outcome<Artifact>> {
271 let job = check!(self.job_for_credential(&a.job, &a.token).await?);
272 let Some(row) = self.pending_artifact(&job.run_id, a.id, a.name.as_deref()).await? else {
273 return Ok(fail(FailureCode::NotFound, "No upload of that artifact is in progress."));
274 };
275 let id = row.id as u64;
276 // 0: the size it was measured at as it was stored (the toolkit's
277 // uploads), rather than one the job says.
278 let size = if a.size > 0 { a.size } else { row.size as u64 };
279 let held = self.run_bytes(&job.run_id, id).await?;
280 let refused = if size > ARTIFACT_MAX_BYTES {
281 Some(format!("It is {} MB; an artifact is at most {} MB.", size / 1_048_576, ARTIFACT_MAX_BYTES / 1_048_576))
282 } else if held + size > RUN_ARTIFACTS_MAX_BYTES {
283 Some(format!("A run's artifacts are at most {} MB together, and this run's hold {} MB.", RUN_ARTIFACTS_MAX_BYTES / 1_048_576, held / 1_048_576))
284 } else {
285 None
286 };
287 if let Some(message) = refused {
288 self.forget_artifacts(&[(id, row.object.clone())]).await?;
289 return Ok(fail(FailureCode::Invalid, message));
290 }
291 let digest = a.digest.filter(|d| d.starts_with("sha256:") && d.len() == 71);
292 self.db
293 .prepare("UPDATE artifacts SET status = 'ready', size = ?, digest = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
294 .bind(&[(size as f64).into(), crate::optional(digest.as_deref()), rfc3339(now_ms()).into(), number(id)])?
295 .run()
296 .await?;
297 let rows = self.artifact_rows("artifacts.id = ?", &[number(id)]).await?;
298 Ok(rows.first().map_or_else(|| fail(FailureCode::NotFound, "No such artifact."), |row| Outcome::Ok(row.view())))
299 }
300
301 /// `artifact_abort`.
302 pub async fn artifact_abort(&self, a: JobArtifactsArgs) -> Result<Outcome<bool>> {
303 let job = check!(self.job_for_credential(&a.job, &a.token).await?);
304 if let Some(row) = self.pending_artifact(&job.run_id, a.id, a.name.as_deref()).await? {
305 self.forget_artifacts(&[(row.id as u64, row.object)]).await?;
306 }
307 Ok(Outcome::Ok(true))
308 }
309
310 /// The ready artifacts of `run`, which must be in the job's repository.
311 async fn readable_by_job(&self, a: &JobArtifactsArgs) -> Result<Outcome<(crate::plan::JobRow, Vec<Row>)>> {
312 let job = check!(self.job_for_credential(&a.job, &a.token).await?);
313 // An id names one artifact of the repository, whichever run made it.
314 if let (Some(id), None) = (a.id, a.run_id.as_deref().filter(|r| !r.is_empty())) {
315 let rows = self
316 .artifact_rows("artifacts.id = ? AND artifacts.repo_id = ? AND artifacts.status = 'ready'", &[number(id), job.repo_id.as_str().into()])
317 .await?;
318 return Ok(Outcome::Ok((job, rows)));
319 }
320 let run = a.run_id.clone().filter(|r| !r.is_empty()).unwrap_or_else(|| job.run_id.clone());
321 let mut rows = self
322 .artifact_rows("artifacts.run_id = ? AND artifacts.repo_id = ? AND artifacts.status = 'ready' ORDER BY artifacts.id", &[run.as_str().into(), job.repo_id.as_str().into()])
323 .await?;
324 if rows.is_empty() && run != job.run_id {
325 let found = self.run_row(&run).await?;
326 if found.is_none_or(|found| found.repo_id != job.repo_id) {
327 return Ok(fail(FailureCode::NotFound, format!("This repository has no run {run}.")));
328 }
329 }
330 if let Some(name) = &a.name {
331 rows.retain(|row| &row.name == name);
332 }
333 if let Some(id) = a.id {
334 rows.retain(|row| row.id as u64 == id);
335 }
336 Ok(Outcome::Ok((job, rows)))
337 }
338
339 /// `job_artifacts`.
340 pub async fn job_artifacts(&self, a: JobArtifactsArgs) -> Result<Outcome<Vec<Artifact>>> {
341 let (_, rows) = check!(self.readable_by_job(&a).await?);
342 Ok(Outcome::Ok(rows.iter().map(Row::view).collect()))
343 }
344
345 fn blob_of(&self, row: &Row, seconds: u64) -> Outcome<ArtifactBlob> {
346 match self.download_token("artifact", &(row.id as u64).to_string(), &row.object, seconds) {
347 Some(blob) => Outcome::Ok(ArtifactBlob { artifact: row.view(), object: row.object.clone(), blob }),
348 // Without a key there are no download links; the API streams
349 // the object itself for those who need only its name.
350 None => Outcome::Ok(ArtifactBlob { artifact: row.view(), object: row.object.clone(), blob: String::new() }),
351 }
352 }
353
354 /// `job_artifact`.
355 pub async fn job_artifact(&self, a: JobArtifactsArgs) -> Result<Outcome<ArtifactBlob>> {
356 let (_, rows) = check!(self.readable_by_job(&a).await?);
357 Ok(match rows.last() {
358 Some(row) => self.blob_of(row, DOWNLOAD_SECONDS),
359 None => fail(FailureCode::NotFound, "No such artifact, or it has expired."),
360 })
361 }
362
363 /// `job_delete_artifact`.
364 pub async fn job_delete_artifact(&self, a: JobArtifactsArgs) -> Result<Outcome<Artifact>> {
365 let job = check!(self.job_for_credential(&a.job, &a.token).await?);
366 let own = JobArtifactsArgs { run_id: Some(job.run_id.clone()), ..a };
367 let (_, rows) = check!(self.readable_by_job(&own).await?);
368 let Some(row) = rows.last() else {
369 return Ok(fail(FailureCode::NotFound, "This run has no such artifact."));
370 };
371 self.forget_artifacts(&[(row.id as u64, row.object.clone())]).await?;
372 let mut gone = row.view();
373 gone.expired = true;
374 Ok(Outcome::Ok(gone))
375 }
376
377 /// `artifacts`.
378 pub async fn artifacts(&self, a: ArtifactsArgs) -> Result<Outcome<ArtifactList>> {
379 let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
380 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
381 };
382 let mut filter = "artifacts.repo_id = ? AND artifacts.status = 'ready'".to_owned();
383 let mut values: Vec<JsValue> = vec![repo.id.as_str().into()];
384 if let Some(run) = a.run.as_deref().filter(|r| !r.is_empty()) {
385 if self.run_in(&a.repo, run).await?.into_result().is_err() {
386 return Ok(fail(FailureCode::NotFound, "No such run."));
387 }
388 filter.push_str(" AND artifacts.run_id = ?");
389 values.push(run.into());
390 }
391 if let Some(name) = a.name.as_deref().filter(|n| !n.is_empty()) {
392 filter.push_str(" AND artifacts.name = ?");
393 values.push(name.into());
394 }
395 let rows = self.artifact_rows(&format!("{filter} ORDER BY artifacts.id DESC"), &values).await?;
396 let per_page = a.per_page.unwrap_or(30).clamp(1, 100) as usize;
397 let page = a.page.unwrap_or(1).max(1) as usize;
398 Ok(Outcome::Ok(ArtifactList {
399 total_count: rows.len() as u64,
400 artifacts: rows.iter().skip((page - 1) * per_page).take(per_page).map(Row::view).collect(),
401 }))
402 }
403
404 /// One ready artifact of a repository the viewer can see.
405 async fn visible_artifact(&self, a: &ArtifactArgs) -> Result<Outcome<Row>> {
406 let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
407 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
408 };
409 let rows = match (a.id, a.run.as_deref(), a.name.as_deref()) {
410 (Some(id), _, _) => self.artifact_rows("artifacts.id = ? AND artifacts.repo_id = ? AND artifacts.status = 'ready'", &[number(id), repo.id.as_str().into()]).await?,
411 (None, Some(run), Some(name)) => {
412 self.artifact_rows(
413 "artifacts.run_id = ? AND artifacts.name = ? AND artifacts.repo_id = ? AND artifacts.status = 'ready'",
414 &[run.into(), name.into(), repo.id.as_str().into()],
415 )
416 .await?
417 }
418 _ => return Ok(fail(FailureCode::Invalid, "Name the artifact by its id, or by its run and name.")),
419 };
420 Ok(rows.into_iter().next().map_or_else(|| fail(FailureCode::NotFound, "No such artifact, or it has expired."), Outcome::Ok))
421 }
422
423 /// `artifact`.
424 pub async fn artifact(&self, a: ArtifactArgs) -> Result<Outcome<Artifact>> {
425 let row = check!(self.visible_artifact(&a).await?);
426 Ok(Outcome::Ok(row.view()))
427 }
428
429 /// `artifact_download`.
430 pub async fn artifact_download(&self, a: ArtifactArgs) -> Result<Outcome<ArtifactBlob>> {
431 let row = check!(self.visible_artifact(&a).await?);
432 Ok(self.blob_of(&row, LINK_SECONDS))
433 }
434
435 /// `delete_artifact`.
436 pub async fn delete_artifact(&self, a: DeleteArtifactArgs) -> Result<Outcome<Artifact>> {
437 let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
438 let rows = self.artifact_rows("artifacts.id = ? AND artifacts.repo_id = ? AND artifacts.status <> 'expired'", &[number(a.id), repo.id.as_str().into()]).await?;
439 let Some(row) = rows.into_iter().next() else {
440 return Ok(fail(FailureCode::NotFound, "No such artifact, or it has expired."));
441 };
442 self.forget_artifacts(&[(row.id as u64, row.object.clone())]).await?;
443 let mut gone = row.view();
444 gone.expired = true;
445 Ok(Outcome::Ok(gone))
446 }
447
448 /// `artifact_retention`.
449 pub async fn artifact_retention(&self, a: ArtifactRetentionArgs) -> Result<Outcome<ArtifactRetention>> {
450 let repo = match a.days {
451 Some(days) => {
452 let Some(actor) = a.viewer.clone() else {
453 return Ok(fail(FailureCode::Unauthenticated, "Sign in to change how long artifacts are kept."));
454 };
455 let repo = check!(self.may(&actor, &a.repo, Capability::ManageSettings).await?);
456 if !(1..=ARTIFACT_RETENTION_MAX_DAYS).contains(&days) {
457 return Ok(fail(FailureCode::Invalid, format!("Artifacts are kept for 1 to {ARTIFACT_RETENTION_MAX_DAYS} days.")));
458 }
459 self.db
460 .prepare(
461 "INSERT INTO repo_settings (repo_id, artifact_retention_days, updated_at, updated_by) VALUES (?1, ?2, ?3, ?4)
462 ON CONFLICT (repo_id) DO UPDATE SET artifact_retention_days = ?2, updated_at = ?3, updated_by = ?4",
463 )
464 .bind(&[repo.id.as_str().into(), f64::from(days).into(), rfc3339(now_ms()).into(), actor.id.as_str().into()])?
465 .run()
466 .await?;
467 repo
468 }
469 None => match self.visible_repo(&a.repo, &a.viewer).await? {
470 Some(repo) => repo,
471 None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")),
472 },
473 };
474 Ok(Outcome::Ok(ArtifactRetention { days: self.retention_setting(&repo.id).await?, maximum_allowed_days: ARTIFACT_RETENTION_MAX_DAYS }))
475 }
476
477 /// Hourly: artifacts past their time, and uploads left unfinished,
478 /// with their objects.
479 pub(crate) async fn sweep_artifacts(&self, now: u64) -> Result<()> {
480 let at = rfc3339(now);
481 self.db
482 .prepare("UPDATE artifacts SET status = 'expired', updated_at = ?1 WHERE (status = 'ready' AND expires_at < ?1) OR (status = 'pending' AND created_at < ?2)")
483 .bind(&[at.as_str().into(), rfc3339(now.saturating_sub(PENDING_MS)).into()])?
484 .run()
485 .await?;
486 #[derive(Deserialize)]
487 struct Gone {
488 id: f64,
489 object: String,
490 }
491 let gone = self.db.prepare("SELECT id, object FROM artifacts WHERE status = 'expired' LIMIT 500").all().await?.results::<Gone>()?;
492 let gone: Vec<(u64, String)> = gone.into_iter().map(|g| (g.id as u64, g.object)).collect();
493 self.forget_artifacts(&gone).await
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn names_follow_github_s_rule() {
503 for good in ["dist", "coverage report", "build (linux, 22)", "my-app_1.0", "résumé"] {
504 assert!(valid_name(good), "{good}");
505 }
506 for bad in ["", " ", "a/b", "a\\b", "a:b", "a*", "what?", "a\"b", "<x>", "a|b", "a\nb", &"x".repeat(257)] {
507 assert!(!valid_name(bad), "{bad:?}");
508 }
509 }
510
511 #[test]
512 fn retention_is_what_was_asked_up_to_the_setting() {
513 let now = 1_000 * DAY_MS;
514 // Nothing asked: the repository's setting.
515 assert_eq!(retention(0, None, 14, now), 14);
516 assert_eq!(retention(0, None, 90, now), 90);
517 // Asked for fewer days, or more than the setting allows.
518 assert_eq!(retention(3, None, 14, now), 3);
519 assert_eq!(retention(30, None, 14, now), 14);
520 // The toolkit says when to expire it instead: rounded up to days.
521 assert_eq!(retention(0, Some(now + 5 * DAY_MS), 30, now), 5);
522 assert_eq!(retention(0, Some(now + 5 * DAY_MS - 1000), 30, now), 5);
523 assert_eq!(retention(0, Some(now - 1), 30, now), 30);
524 // A setting out of range is held to 1 to 90 days.
525 assert_eq!(retention(0, None, 0, now), 1);
526 assert_eq!(retention(200, None, 400, now), 90);
527 }
528}