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

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 artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.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 }
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.194 if let Some(why) = self.storage_refused(&job.namespace, a.size).await? {
195 return Ok(fail(FailureCode::Limit, why));
196 }
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.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}

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