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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1 | //! Managed workflows (`g1t_actions::managed`): workflows g1t's services |
| 2 | //! install into a repository without committing them. They are kept here, | |
| 3 | //! read beside the repository's own files wherever its workflows are read | |
| 4 | //! (`sync.rs` `read_for`), and so listed, started, run and re-run as any | |
| 5 | //! other workflow is. | |
| 6 | ||
| 7 | use g1t_actions::managed; | |
| 8 | use g1t_actions::workflow; | |
| 9 | use g1t_contracts::actions::{RemoveManagedWorkflowArgs, SetManagedDeployArgs, SetManagedWorkflowArgs}; | |
| 10 | use g1t_contracts::time::rfc3339; | |
| 11 | use g1t_contracts::{FailureCode, Outcome}; | |
| 12 | use g1t_kit::now_ms; | |
| 13 | use serde::Deserialize; | |
| 14 | use worker::Result; | |
| 15 | ||
| 16 | use crate::sync::WorkflowFile; | |
| 17 | use crate::{Actions, fail}; | |
| 18 | ||
| 19 | /// Whether `name` can end a managed workflow's path. | |
| 20 | fn valid_name(name: &str) -> bool { | |
| 21 | !name.is_empty() && name.len() <= 64 && name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') | |
| 22 | } | |
| 23 | ||
| 24 | #[derive(Deserialize)] | |
| 25 | struct Row { | |
| 26 | name: String, | |
| 27 | source: String, | |
| 28 | } | |
| 29 | ||
| 30 | impl Actions { | |
| 31 | /// `set_managed_workflow`. | |
| 32 | pub async fn set_managed_workflow(&self, a: SetManagedWorkflowArgs) -> Result<Outcome<bool>> { | |
| 33 | if !valid_name(&a.name) { | |
| 34 | return Ok(fail(FailureCode::Invalid, "A managed workflow's name is lowercase letters, digits and hyphens.")); | |
| 35 | } | |
| 36 | if let Err(problem) = workflow::parse(&a.source) { | |
| 37 | return Ok(fail(FailureCode::Invalid, format!("The managed workflow does not read as a workflow: {problem}"))); | |
| 38 | } | |
| 39 | let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else { | |
| 40 | return Ok(fail(FailureCode::NotFound, "No such repository.")); | |
| 41 | }; | |
| 42 | self.db | |
| 43 | .prepare( | |
| 44 | "INSERT INTO managed_workflows (repo_id, name, source, origin, updated_at) VALUES (?, ?, ?, ?, ?) | |
| 45 | ON CONFLICT (repo_id, name) DO UPDATE SET source = excluded.source, origin = excluded.origin, updated_at = excluded.updated_at", | |
| 46 | ) | |
| 47 | .bind(&[a.repo_id.as_str().into(), a.name.as_str().into(), a.source.as_str().into(), a.origin.as_str().into(), rfc3339(now_ms()).into()])? | |
| 48 | .run() | |
| 49 | .await?; | |
| 50 | // Listed, and on schedule, at once. | |
| 51 | self.sync(&repo, &ws).await?; | |
| 52 | Ok(Outcome::Ok(true)) | |
| 53 | } | |
| 54 | ||
| 55 | /// `remove_managed_workflow`. | |
| 56 | pub async fn remove_managed_workflow(&self, a: RemoveManagedWorkflowArgs) -> Result<Outcome<bool>> { | |
| 57 | let removed = self | |
| 58 | .db | |
| 59 | .prepare("DELETE FROM managed_workflows WHERE repo_id = ? AND name = ? RETURNING name") | |
| 60 | .bind(&[a.repo_id.as_str().into(), a.name.as_str().into()])? | |
| 61 | .first::<serde_json::Value>(None) | |
| 62 | .await? | |
| 63 | .is_some(); | |
| 64 | if removed && let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? { | |
| 65 | self.sync(&repo, &ws).await?; | |
| 66 | } | |
| 67 | Ok(Outcome::Ok(removed)) | |
| 68 | } | |
| 69 | ||
| 70 | /// `set_managed_deploy`. | |
| 71 | pub async fn set_managed_deploy(&self, a: SetManagedDeployArgs) -> Result<Outcome<bool>> { | |
| 72 | let name = format!("deploy-{}", a.project); | |
| 73 | let settings = managed::DeploySettings { | |
| 74 | project: a.project, | |
| 75 | production_branch: a.production_branch, | |
| 76 | root_dir: a.root_dir, | |
| 77 | build_command: a.build_command, | |
| 78 | output_dir: a.output_dir, | |
| 79 | previews: a.previews, | |
| 80 | production: a.production, | |
| 81 | }; | |
| 82 | match managed::deploy_workflow(&settings) { | |
| 83 | Some(source) => self.set_managed_workflow(SetManagedWorkflowArgs { repo_id: a.repo_id, name, source, origin: "deployments".to_owned() }).await, | |
| 84 | None => { | |
| 85 | self.remove_managed_workflow(RemoveManagedWorkflowArgs { repo_id: a.repo_id, name }).await?; | |
| 86 | Ok(Outcome::Ok(false)) | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | /// The managed workflows that run beside `files`, a repository's own: | |
| 92 | /// each one no file of the same `name:` takes the place of. | |
| 93 | pub(crate) async fn managed_beside(&self, repo_id: &str, files: &[WorkflowFile]) -> Result<Vec<WorkflowFile>> { | |
| 94 | let rows = self | |
| 95 | .db | |
| 96 | .prepare("SELECT name, source FROM managed_workflows WHERE repo_id = ? ORDER BY name") | |
| 97 | .bind(&[repo_id.into()])? | |
| 98 | .all() | |
| 99 | .await? | |
| 100 | .results::<Row>()?; | |
| 101 | if rows.is_empty() { | |
| 102 | return Ok(Vec::new()); | |
| 103 | } | |
| 104 | let installed = rows.into_iter().map(|row| (managed::path(&row.name), row.source)).collect(); | |
| 105 | Ok(managed::prefer_repository(files.iter().map(|file| (file.path.as_str(), file.source.as_str())), installed) | |
| 106 | .into_iter() | |
| 107 | .map(|(path, source)| WorkflowFile { path, source }) | |
| 108 | .collect()) | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | #[cfg(test)] | |
| 113 | mod tests { | |
| 114 | use super::*; | |
| 115 | ||
| 116 | #[test] | |
| 117 | fn managed_workflows_are_named_as_files() { | |
| 118 | assert!(valid_name("deploy") && valid_name("code-scanning-2")); | |
| 119 | for bad in ["", "Deploy", "a/b", "a.yml", "../x", &"a".repeat(65)] { | |
| 120 | assert!(!valid_name(bad), "{bad}"); | |
| 121 | } | |
| 122 | } | |
| 123 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.