A repository can run a managed workflow: one installed by g1t's own services without a file in the repository. It is listed, started, run and re-run like any workflow in `.g1t/workflows/`, under the path `managed/<name>.yml`, and a workflow of the same `name:` in the repository runs in its place, so moving one into the repository is committing its source. The Actions page says a managed workflow is managed by g1t rather than linking to a file. Nothing installs one yet; the deploy workflow deployments will install, which calls g1t's reusable deploy workflow with a project's settings as its inputs and reports as `g1t / deploy`, is written and tested but not installed.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
10 files+379−190/10 viewed
| 30 | 30 | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 31 | 31 | import { Form, Link, useLoaderData, useSearchParams } from "react-router"; | |
| 32 | 32 | ||
| 33 | − | import type { Annotation, EnvironmentReviewer, Job, JobSummary, PendingDeployment, RunApproval, RunAttempt, StepState } from "@g1t/contracts"; | |
| 33 | + | import { type Annotation, type EnvironmentReviewer, type Job, type JobSummary, type PendingDeployment, type RunApproval, type RunAttempt, type StepState, isManagedWorkflow } from "@g1t/contracts"; | |
| 34 | 34 | ||
| 35 | 35 | import type { Route } from "./+types/actions-run"; | |
| 36 | 36 | import { page } from "../../lib/meta"; | |
| ⋯ | |||
| 1013 | 1013 | <NavUnit key={unit.key} unit={unit} selected={selected} jobHref={jobHref} /> | |
| 1014 | 1014 | ))} | |
| 1015 | 1015 | <p className={heading}>Run details</p> | |
| 1016 | − | <Link to={`${base}/blob/${run.sha}/${run.path}`} className={navRow(false)}> | |
| 1017 | − | <FileCode size={14} className="shrink-0" /> | |
| 1018 | − | Workflow file | |
| 1019 | − | </Link> | |
| 1016 | + | {!isManagedWorkflow(run.path) && ( | |
| 1017 | + | <Link to={`${base}/blob/${run.sha}/${run.path}`} className={navRow(false)}> | |
| 1018 | + | <FileCode size={14} className="shrink-0" /> | |
| 1019 | + | Workflow file | |
| 1020 | + | </Link> | |
| 1021 | + | )} | |
| 1020 | 1022 | </nav> | |
| 1021 | 1023 | ); | |
| 1022 | 1024 | } | |
| 2 | 2 | import { useEffect, useRef, useState } from "react"; | |
| 3 | 3 | import { Form, Link, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts"; | |
| 5 | + | import { type DispatchInput, type Workflow, type WorkflowRun, isManagedWorkflow } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/actions"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| ⋯ | |||
| 303 | 303 | <div className="flex flex-wrap items-start justify-between gap-3"> | |
| 304 | 304 | <div className="min-w-0"> | |
| 305 | 305 | <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3> | |
| 306 | − | <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg"> | |
| 307 | − | <FileCode2 size={12} /> | |
| 308 | − | {workflow.path} | |
| 309 | − | </Link> | |
| 306 | + | {isManagedWorkflow(workflow.path) ? ( | |
| 307 | + | <span className="inline-flex items-center gap-1 text-xs text-faint"> | |
| 308 | + | <BadgeCheck size={12} /> | |
| 309 | + | Managed by g1t: it runs here without a file in the repository. A workflow named {workflow.name} in .g1t/workflows/ takes its place. | |
| 310 | + | </span> | |
| 311 | + | ) : ( | |
| 312 | + | <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg"> | |
| 313 | + | <FileCode2 size={12} /> | |
| 314 | + | {workflow.path} | |
| 315 | + | </Link> | |
| 316 | + | )} | |
| 310 | 317 | {workflow.events.length > 0 && ( | |
| 311 | 318 | <p className="mt-1.5 text-xs text-muted">On {workflow.events.map((e) => EVENT_WORDS[e] ?? e).join(", ")}</p> | |
| 312 | 319 | )} | |
| 13 | 13 | pub mod events; | |
| 14 | 14 | pub mod expr; | |
| 15 | 15 | pub mod filter; | |
| 16 | + | pub mod managed; | |
| 16 | 17 | pub mod mask; | |
| 17 | 18 | pub mod matrix; | |
| 18 | 19 | pub mod permissions; |
| 1 | + | //! Managed workflows: workflows g1t (or a workspace) installs into a | |
| 2 | + | //! repository without committing them. One runs like any workflow in | |
| 3 | + | //! `.g1t/workflows/` (the same runs, logs, re-runs and checks), from a | |
| 4 | + | //! source kept beside the repository rather than in it, under a path in | |
| 5 | + | //! [`FOLDER`] that no file can have. | |
| 6 | + | //! | |
| 7 | + | //! A repository takes one over by having a workflow of the same `name:` in | |
| 8 | + | //! `.g1t/workflows/`: that file runs instead ([`prefer_repository`]). So | |
| 9 | + | //! moving a managed workflow into the repository is committing its source. | |
| 10 | + | //! | |
| 11 | + | //! [`deploy_workflow`] writes the one deployments install: it calls g1t's | |
| 12 | + | //! own reusable deploy workflow with the project's settings as its inputs. | |
| 13 | + | ||
| 14 | + | use crate::workflow; | |
| 15 | + | ||
| 16 | + | /// Where managed workflows' paths start. Not a folder in any repository: | |
| 17 | + | /// workflow files are read from `.g1t/workflows/` and `.github/workflows/`. | |
| 18 | + | pub const FOLDER: &str = "managed"; | |
| 19 | + | ||
| 20 | + | /// The `name:` of the workflow deployments install, and of its calling | |
| 21 | + | /// job, so the called job's check is `g1t / deploy`, the name a g1t.page | |
| 22 | + | /// build's status has always had. | |
| 23 | + | pub const DEPLOY_NAME: &str = "g1t"; | |
| 24 | + | ||
| 25 | + | /// The reusable workflow the deploy workflow calls. | |
| 26 | + | pub const DEPLOY_CALLS: &str = "g1t/workflows/.g1t/workflows/deploy.yml@v1"; | |
| 27 | + | ||
| 28 | + | /// The path a managed workflow called `name` runs under. | |
| 29 | + | pub fn path(name: &str) -> String { | |
| 30 | + | format!("{FOLDER}/{name}.yml") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Whether `path` is a managed workflow's. | |
| 34 | + | pub fn is_managed(path: &str) -> bool { | |
| 35 | + | path.strip_prefix(FOLDER).is_some_and(|rest| rest.starts_with('/')) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// A project's deployment settings, as the deploy workflow's triggers and | |
| 39 | + | /// inputs. | |
| 40 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 41 | + | pub struct DeploySettings { | |
| 42 | + | /// The project's slug, which names its app. | |
| 43 | + | pub project: String, | |
| 44 | + | /// The branch production builds from: the repository's default branch. | |
| 45 | + | pub production_branch: String, | |
| 46 | + | /// The project's folder in the repository; empty or `.` for the root. | |
| 47 | + | pub root_dir: String, | |
| 48 | + | /// Empty: what deployments detect. | |
| 49 | + | pub build_command: String, | |
| 50 | + | /// Empty: what deployments detect. | |
| 51 | + | pub output_dir: String, | |
| 52 | + | /// Whether pull requests get previews. | |
| 53 | + | pub previews: bool, | |
| 54 | + | /// Whether pushes to the production branch deploy. | |
| 55 | + | pub production: bool, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// A string as YAML reads it back unchanged: JSON's quoting is YAML's. | |
| 59 | + | fn quoted(text: &str) -> String { | |
| 60 | + | serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_owned()) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The workflow deployments install for a project, or None when it neither | |
| 64 | + | /// previews nor deploys production, so nothing would start it. | |
| 65 | + | pub fn deploy_workflow(settings: &DeploySettings) -> Option<String> { | |
| 66 | + | if !settings.previews && !settings.production { | |
| 67 | + | return None; | |
| 68 | + | } | |
| 69 | + | let mut on = String::new(); | |
| 70 | + | if settings.production { | |
| 71 | + | on.push_str(&format!(" push:\n branches: [{}]\n", quoted(&settings.production_branch))); | |
| 72 | + | } | |
| 73 | + | if settings.previews { | |
| 74 | + | // Previews come down on the server when their pull request closes. | |
| 75 | + | on.push_str(" pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n"); | |
| 76 | + | } | |
| 77 | + | let root_dir = if settings.root_dir.trim().is_empty() { "." } else { settings.root_dir.trim() }; | |
| 78 | + | Some(format!( | |
| 79 | + | "# Managed by g1t, from the deployment settings of the project {project}.\n\ | |
| 80 | + | # It is not in the repository. A workflow named \"{name}\" in .g1t/workflows/\n\ | |
| 81 | + | # runs in its place.\n\ | |
| 82 | + | name: {name}\n\ | |
| 83 | + | on:\n\ | |
| 84 | + | {on}\ | |
| 85 | + | # A newer push replaces a build still running for the same branch or\n\ | |
| 86 | + | # pull request.\n\ | |
| 87 | + | concurrency:\n \ | |
| 88 | + | group: ${{{{ github.workflow }}}}-{project_key}-${{{{ github.event.pull_request.number || github.ref }}}}\n \ | |
| 89 | + | cancel-in-progress: true\n\ | |
| 90 | + | permissions:\n \ | |
| 91 | + | contents: read\n \ | |
| 92 | + | deployments: write\n \ | |
| 93 | + | id-token: write\n\ | |
| 94 | + | jobs:\n \ | |
| 95 | + | {name}:\n \ | |
| 96 | + | name: {name}\n \ | |
| 97 | + | uses: {calls}\n \ | |
| 98 | + | with:\n \ | |
| 99 | + | project: {project}\n \ | |
| 100 | + | root-dir: {root_dir}\n \ | |
| 101 | + | build-command: {build_command}\n \ | |
| 102 | + | output-dir: {output_dir}\n \ | |
| 103 | + | secrets: inherit\n", | |
| 104 | + | name = DEPLOY_NAME, | |
| 105 | + | calls = DEPLOY_CALLS, | |
| 106 | + | on = on, | |
| 107 | + | project = quoted(&settings.project), | |
| 108 | + | project_key = settings.project.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect::<String>(), | |
| 109 | + | root_dir = quoted(root_dir), | |
| 110 | + | build_command = quoted(settings.build_command.trim()), | |
| 111 | + | output_dir = quoted(settings.output_dir.trim()), | |
| 112 | + | )) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// A workflow's name, as it would be listed: its `name:`, else its path. | |
| 116 | + | fn listed_name(path: &str, source: &str) -> String { | |
| 117 | + | workflow::parse(source).map(|parsed| parsed.display_name(path)).unwrap_or_else(|_| path.to_owned()) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// The managed workflows that run beside a repository's own `files`, each | |
| 121 | + | /// as `(path, source)`: every one, except those a file of the same `name:` | |
| 122 | + | /// takes the place of. | |
| 123 | + | pub fn prefer_repository<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>, managed: Vec<(String, String)>) -> Vec<(String, String)> { | |
| 124 | + | let theirs: Vec<String> = files.into_iter().map(|(path, source)| listed_name(path, source)).collect(); | |
| 125 | + | managed.into_iter().filter(|(path, source)| !theirs.contains(&listed_name(path, source))).collect() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[cfg(test)] | |
| 129 | + | mod tests { | |
| 130 | + | use super::*; | |
| 131 | + | use crate::workflow::Severity; | |
| 132 | + | ||
| 133 | + | fn settings() -> DeploySettings { | |
| 134 | + | DeploySettings { | |
| 135 | + | project: "web".into(), | |
| 136 | + | production_branch: "main".into(), | |
| 137 | + | root_dir: "apps/web".into(), | |
| 138 | + | build_command: String::new(), | |
| 139 | + | output_dir: "dist".into(), | |
| 140 | + | previews: true, | |
| 141 | + | production: true, | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | #[test] | |
| 146 | + | fn the_deploy_workflow_is_a_workflow_g1t_runs_as_it_is() { | |
| 147 | + | let source = deploy_workflow(&settings()).unwrap(); | |
| 148 | + | let parsed = workflow::parse(&source).unwrap(); | |
| 149 | + | assert_eq!(parsed.name.as_deref(), Some("g1t")); | |
| 150 | + | assert!(parsed.trigger("push").is_some() && parsed.trigger("pull_request").is_some()); | |
| 151 | + | assert!(!parsed.notes.iter().any(|note| note.severity != Severity::Info), "{:?}", parsed.notes); | |
| 152 | + | let job = &parsed.jobs[0]; | |
| 153 | + | assert_eq!(job.id, "g1t"); | |
| 154 | + | assert!(source.contains("uses: g1t/workflows/.g1t/workflows/deploy.yml@v1")); | |
| 155 | + | assert!(source.contains("root-dir: \"apps/web\"") && source.contains("output-dir: \"dist\"") && source.contains("build-command: \"\"")); | |
| 156 | + | assert!(source.contains("branches: [\"main\"]")); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[test] | |
| 160 | + | fn what_a_project_turns_off_starts_nothing() { | |
| 161 | + | let previews_only = deploy_workflow(&DeploySettings { production: false, ..settings() }).unwrap(); | |
| 162 | + | let parsed = workflow::parse(&previews_only).unwrap(); | |
| 163 | + | assert!(parsed.trigger("push").is_none() && parsed.trigger("pull_request").is_some()); | |
| 164 | + | let production_only = deploy_workflow(&DeploySettings { previews: false, ..settings() }).unwrap(); | |
| 165 | + | assert!(workflow::parse(&production_only).unwrap().trigger("pull_request").is_none()); | |
| 166 | + | assert_eq!(deploy_workflow(&DeploySettings { previews: false, production: false, ..settings() }), None); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[test] | |
| 170 | + | fn settings_are_quoted_so_they_stay_text() { | |
| 171 | + | let tricky = DeploySettings { build_command: "npm run build -- --base \"/x\": yes".into(), root_dir: String::new(), ..settings() }; | |
| 172 | + | let source = deploy_workflow(&tricky).unwrap(); | |
| 173 | + | let parsed = workflow::parse(&source).unwrap(); | |
| 174 | + | let with = &parsed.jobs[0].raw["with"]; | |
| 175 | + | assert_eq!(with["build-command"], "npm run build -- --base \"/x\": yes"); | |
| 176 | + | assert_eq!(with["root-dir"], "."); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | #[test] | |
| 180 | + | fn a_repository_workflow_of_the_same_name_takes_its_place() { | |
| 181 | + | let deploy = (path("deploy"), deploy_workflow(&settings()).unwrap()); | |
| 182 | + | let other = (path("scan"), "name: Scan\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n".to_owned()); | |
| 183 | + | let own = "name: g1t\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n"; | |
| 184 | + | let kept = prefer_repository([(".g1t/workflows/g1t.yml", own)], vec![deploy.clone(), other.clone()]); | |
| 185 | + | assert_eq!(kept, vec![other.clone()]); | |
| 186 | + | let kept = prefer_repository([(".g1t/workflows/ci.yml", "name: CI\non: push\njobs: {}\n")], vec![deploy.clone(), other]); | |
| 187 | + | assert_eq!(kept.len(), 2); | |
| 188 | + | assert!(is_managed(&deploy.0) && !is_managed(".g1t/workflows/g1t.yml") && !is_managed("managedx/a.yml")); | |
| 189 | + | } | |
| 190 | + | } |
| 766 | 766 | pub note: Option<String>, | |
| 767 | 767 | } | |
| 768 | 768 | ||
| 769 | + | /// `set_managed_workflow`: installs, or replaces, a managed workflow in a | |
| 770 | + | /// repository (`g1t_actions::managed`): one that runs as if it were in | |
| 771 | + | /// `.g1t/workflows/` without being committed there, under the path | |
| 772 | + | /// `managed/<name>.yml`. For g1t's own services; never reached from the API. | |
| 773 | + | /// Refused when `source` is not a workflow g1t can read. Returns `bool`. | |
| 774 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 775 | + | #[serde(rename_all = "camelCase")] | |
| 776 | + | pub struct SetManagedWorkflowArgs { | |
| 777 | + | pub repo_id: String, | |
| 778 | + | /// Letters, digits and hyphens: the file name its path ends with. | |
| 779 | + | pub name: String, | |
| 780 | + | pub source: String, | |
| 781 | + | /// What keeps it: `deployments`, or a workspace's id. | |
| 782 | + | pub origin: String, | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | /// `remove_managed_workflow`: takes a managed workflow out of a repository. | |
| 786 | + | /// Its runs stay. Returns `bool`, whether there was one. | |
| 787 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 788 | + | #[serde(rename_all = "camelCase")] | |
| 789 | + | pub struct RemoveManagedWorkflowArgs { | |
| 790 | + | pub repo_id: String, | |
| 791 | + | pub name: String, | |
| 792 | + | } | |
| 793 | + | ||
| 769 | 794 | /// `resolve_settings`: the secrets and variables one reader gets, for the | |
| 770 | 795 | /// services that hand them out (the deployments service). Returns | |
| 771 | 796 | /// `ResolvedSettings`. |
| 430 | 430 | export type ArtifactBlob = { artifact: Artifact; object: string; blob: string }; | |
| 431 | 431 | ||
| 432 | 432 | export type ArtifactRetention = { days: number; maximum_allowed_days: number }; | |
| 433 | + | ||
| 434 | + | /** | |
| 435 | + | * Whether a workflow's path is a managed workflow's: one g1t installed in | |
| 436 | + | * the repository without committing it (crates/actions/src/managed.rs), so | |
| 437 | + | * no file in the repository holds it. | |
| 438 | + | */ | |
| 439 | + | export function isManagedWorkflow(path: string): boolean { | |
| 440 | + | return path.startsWith("managed/"); | |
| 441 | + | } |
| 1 | + | -- Managed workflows (src/managed.rs): workflows installed into a repository | |
| 2 | + | -- without being committed to it, such as the one deployments install for a | |
| 3 | + | -- project. Each runs under the path `managed/<name>.yml`; a workflow of the | |
| 4 | + | -- same `name:` in the repository's own folder runs in its place. | |
| 5 | + | CREATE TABLE IF NOT EXISTS managed_workflows ( | |
| 6 | + | repo_id TEXT NOT NULL, | |
| 7 | + | name TEXT NOT NULL, | |
| 8 | + | source TEXT NOT NULL, | |
| 9 | + | -- What installed it and keeps it: `deployments`, or a workspace's id. | |
| 10 | + | origin TEXT NOT NULL, | |
| 11 | + | updated_at TEXT NOT NULL, | |
| 12 | + | PRIMARY KEY (repo_id, name) | |
| 13 | + | ); |
| 27 | 27 | ||
| 28 | 28 | mod run_artifacts; | |
| 29 | 29 | mod cache; | |
| 30 | + | mod managed; | |
| 30 | 31 | mod mirrored; | |
| 31 | 32 | mod payload; | |
| 32 | 33 | mod plan; | |
| ⋯ | |||
| 251 | 252 | "set_setting" => reply(&service.set_setting(args(body)?).await?), | |
| 252 | 253 | "delete_setting" => reply(&service.delete_setting(args(body)?).await?), | |
| 253 | 254 | "resolve_settings" => reply(&service.resolve_settings(args(body)?).await?), | |
| 255 | + | // Managed workflows: g1t's services' side only. | |
| 256 | + | "set_managed_workflow" => reply(&service.set_managed_workflow(args(body)?).await?), | |
| 257 | + | "remove_managed_workflow" => reply(&service.remove_managed_workflow(args(body)?).await?), | |
| 254 | 258 | "job_spec" => reply(&service.job_spec(args(body)?).await?), | |
| 255 | 259 | "job_auth" => reply(&service.job_auth(args(body)?).await?), | |
| 256 | 260 | "job_action" => reply(&service.job_action(args(body)?).await?), | |
| 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, 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 | + | /// The managed workflows that run beside `files`, a repository's own: | |
| 71 | + | /// each one no file of the same `name:` takes the place of. | |
| 72 | + | pub(crate) async fn managed_beside(&self, repo_id: &str, files: &[WorkflowFile]) -> Result<Vec<WorkflowFile>> { | |
| 73 | + | let rows = self | |
| 74 | + | .db | |
| 75 | + | .prepare("SELECT name, source FROM managed_workflows WHERE repo_id = ? ORDER BY name") | |
| 76 | + | .bind(&[repo_id.into()])? | |
| 77 | + | .all() | |
| 78 | + | .await? | |
| 79 | + | .results::<Row>()?; | |
| 80 | + | if rows.is_empty() { | |
| 81 | + | return Ok(Vec::new()); | |
| 82 | + | } | |
| 83 | + | let installed = rows.into_iter().map(|row| (managed::path(&row.name), row.source)).collect(); | |
| 84 | + | Ok(managed::prefer_repository(files.iter().map(|file| (file.path.as_str(), file.source.as_str())), installed) | |
| 85 | + | .into_iter() | |
| 86 | + | .map(|(path, source)| WorkflowFile { path, source }) | |
| 87 | + | .collect()) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[cfg(test)] | |
| 92 | + | mod tests { | |
| 93 | + | use super::*; | |
| 94 | + | ||
| 95 | + | #[test] | |
| 96 | + | fn managed_workflows_are_named_as_files() { | |
| 97 | + | assert!(valid_name("deploy") && valid_name("code-scanning-2")); | |
| 98 | + | for bad in ["", "Deploy", "a/b", "a.yml", "../x", &"a".repeat(65)] { | |
| 99 | + | assert!(!valid_name(bad), "{bad}"); | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } |
| 62 | 62 | ||
| 63 | 63 | /// The workflow files a repository runs: `.g1t/workflows`, and on a | |
| 64 | 64 | /// mirror in CI failover or taken over, `.github/workflows` too (see | |
| 65 | − | /// mirrored.rs), where g1t's own stands in for one of the same name. | |
| 65 | + | /// mirrored.rs), where g1t's own stands in for one of the same name; | |
| 66 | + | /// then the managed workflows installed in it that none of those take | |
| 67 | + | /// the place of (managed.rs). | |
| 66 | 68 | pub async fn read_for(&self, repo: &Repo, actor: &User, git_ref: Option<&str>, github: bool) -> Result<Read> { | |
| 67 | 69 | let path = RepoPath { | |
| 68 | 70 | namespace: repo.namespace.clone(), | |
| 69 | 71 | name: repo.name.clone(), | |
| 70 | 72 | }; | |
| 71 | − | if !github { | |
| 72 | − | return self.read_workflows(&path, actor, git_ref).await; | |
| 73 | − | } | |
| 74 | − | let read = self.read_folders(&path, actor, git_ref, &[FOLDER, crate::mirrored::GITHUB_FOLDER]).await?; | |
| 75 | − | Ok(Read { | |
| 76 | − | files: crate::mirrored::prefer_g1t(read.files), | |
| 77 | − | head: read.head, | |
| 78 | − | }) | |
| 73 | + | let mut read = if github { | |
| 74 | + | let read = self.read_folders(&path, actor, git_ref, &[FOLDER, crate::mirrored::GITHUB_FOLDER]).await?; | |
| 75 | + | Read { | |
| 76 | + | files: crate::mirrored::prefer_g1t(read.files), | |
| 77 | + | head: read.head, | |
| 78 | + | } | |
| 79 | + | } else { | |
| 80 | + | self.read_workflows(&path, actor, git_ref).await? | |
| 81 | + | }; | |
| 82 | + | // And the managed workflows installed beside them (managed.rs). | |
| 83 | + | let managed = self.managed_beside(&repo.id, &read.files).await?; | |
| 84 | + | read.files.extend(managed); | |
| 85 | + | Ok(read) | |
| 79 | 86 | } | |
| 80 | 87 | ||
| 81 | 88 | async fn read_folders(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>, folders: &[&str]) -> Result<Read> { |