Merge branch 'worktree-agent-a209db8188a251e77' into claude/cool-newton-7nm8r9
# Conflicts: # apps/api/src/operations.rs
28 files+1069−3512 files not counted0/28 viewed
| 499 | 499 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 500 | 500 | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 501 | 501 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 502 | + | Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), | |
| 503 | + | Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), | |
| 504 | + | Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), | |
| 505 | + | Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), | |
| 502 | 506 | ], | |
| 503 | 507 | ), | |
| 504 | 508 | ( |
| 714 | 714 | } | |
| 715 | 715 | ||
| 716 | 716 | impl Op { | |
| 717 | − | pub const ALL: [Op; 382] = [ | |
| 717 | + | pub const ALL: [Op; 386] = [ | |
| 718 | 718 | Op::Whoami, | |
| 719 | 719 | Op::GetWorkspace, | |
| 720 | 720 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1057 | 1057 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 1058 | 1058 | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 1059 | 1059 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 1060 | + | Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), | |
| 1061 | + | Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), | |
| 1062 | + | Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), | |
| 1063 | + | Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), | |
| 1060 | 1064 | Op::Tokens(TokenOp::GetTokenPolicy), | |
| 1061 | 1065 | Op::Tokens(TokenOp::SetTokenPolicy), | |
| 1062 | 1066 | Op::Tokens(TokenOp::ListMemberTokens), | |
| 29 | 29 | CreateRepositoryDispatch, | |
| 30 | 30 | GetWorkspaceWorkflowPermissions, | |
| 31 | 31 | SetWorkspaceWorkflowPermissions, | |
| 32 | + | ListWorkspaceManagedWorkflows, | |
| 33 | + | CreateWorkspaceManagedWorkflow, | |
| 34 | + | UpdateWorkspaceManagedWorkflow, | |
| 35 | + | DeleteWorkspaceManagedWorkflow, | |
| 32 | 36 | } | |
| 33 | 37 | ||
| 34 | 38 | impl ProtectionOp { | |
| 35 | 39 | /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test | |
| 36 | 40 | /// checks against this. | |
| 37 | 41 | #[cfg(test)] | |
| 38 | − | pub const ALL: [ProtectionOp; 14] = [ | |
| 42 | + | pub const ALL: [ProtectionOp; 18] = [ | |
| 39 | 43 | ProtectionOp::UpdateEnvironment, | |
| 40 | 44 | ProtectionOp::DeleteEnvironment, | |
| 41 | 45 | ProtectionOp::GetPendingDeployments, | |
| ⋯ | |||
| 50 | 54 | ProtectionOp::CreateRepositoryDispatch, | |
| 51 | 55 | ProtectionOp::GetWorkspaceWorkflowPermissions, | |
| 52 | 56 | ProtectionOp::SetWorkspaceWorkflowPermissions, | |
| 57 | + | ProtectionOp::ListWorkspaceManagedWorkflows, | |
| 58 | + | ProtectionOp::CreateWorkspaceManagedWorkflow, | |
| 59 | + | ProtectionOp::UpdateWorkspaceManagedWorkflow, | |
| 60 | + | ProtectionOp::DeleteWorkspaceManagedWorkflow, | |
| 53 | 61 | ]; | |
| 54 | 62 | ||
| 55 | 63 | pub fn name(self) -> &'static str { | |
| ⋯ | |||
| 68 | 76 | ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch", | |
| 69 | 77 | ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions", | |
| 70 | 78 | ProtectionOp::SetWorkspaceWorkflowPermissions => "set_workspace_workflow_permissions", | |
| 79 | + | ProtectionOp::ListWorkspaceManagedWorkflows => "list_workspace_managed_workflows", | |
| 80 | + | ProtectionOp::CreateWorkspaceManagedWorkflow => "create_workspace_managed_workflow", | |
| 81 | + | ProtectionOp::UpdateWorkspaceManagedWorkflow => "update_workspace_managed_workflow", | |
| 82 | + | ProtectionOp::DeleteWorkspaceManagedWorkflow => "delete_workspace_managed_workflow", | |
| 71 | 83 | } | |
| 72 | 84 | } | |
| 73 | 85 | ||
| 74 | 86 | /// Whether it is about one repository, named by `repo`; the rest are a | |
| 75 | 87 | /// workspace's. | |
| 76 | 88 | pub fn needs_repo(self) -> bool { | |
| 77 | − | !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions) | |
| 89 | + | !self.managed() && !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Whether it is about a workspace's managed workflows. | |
| 93 | + | fn managed(self) -> bool { | |
| 94 | + | matches!( | |
| 95 | + | self, | |
| 96 | + | ProtectionOp::ListWorkspaceManagedWorkflows | |
| 97 | + | | ProtectionOp::CreateWorkspaceManagedWorkflow | |
| 98 | + | | ProtectionOp::UpdateWorkspaceManagedWorkflow | |
| 99 | + | | ProtectionOp::DeleteWorkspaceManagedWorkflow | |
| 100 | + | ) | |
| 78 | 101 | } | |
| 79 | 102 | ||
| 80 | 103 | pub fn title(self) -> &'static str { | |
| ⋯ | |||
| 93 | 116 | ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event", | |
| 94 | 117 | ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions", | |
| 95 | 118 | ProtectionOp::SetWorkspaceWorkflowPermissions => "Set a workspace's default workflow permissions", | |
| 119 | + | ProtectionOp::ListWorkspaceManagedWorkflows => "List the workflows a workspace manages", | |
| 120 | + | ProtectionOp::CreateWorkspaceManagedWorkflow => "Add a workflow a workspace manages", | |
| 121 | + | ProtectionOp::UpdateWorkspaceManagedWorkflow => "Update a workflow a workspace manages", | |
| 122 | + | ProtectionOp::DeleteWorkspaceManagedWorkflow => "Remove a workflow a workspace manages", | |
| 96 | 123 | } | |
| 97 | 124 | } | |
| 98 | 125 | ||
| ⋯ | |||
| 111 | 138 | ProtectionOp::SetActionsAccess => "Set access_level: none, or organization to let the workspace's other private repositories use this repository's actions and reusable workflows (user is read as organization). Needs the Admin role.", | |
| 112 | 139 | ProtectionOp::GetWorkspaceWorkflowPermissions => "A workspace's policy for its repositories' job tokens: default_workflow_permissions (read, the default, or write) is what a repository made from now on gets until it chooses; max_workflow_permissions (write, the default, or read) is the most any repository's default may be, so read holds every repository to read-only; can_approve_pull_request_reviews (off by default) lets its repositories allow jobs to open and approve pull requests. Members only.", | |
| 113 | 140 | ProtectionOp::SetWorkspaceWorkflowPermissions => "Change a workspace's default_workflow_permissions, max_workflow_permissions and can_approve_pull_request_reviews; fields left out stay as they are. A maximum of read makes the default read too. Owners only.", | |
| 141 | + | ProtectionOp::ListWorkspaceManagedWorkflows => "The workflows a workspace's owners added to its repositories, g1t's required workflows: each runs in every repository it targets as that repository's own workflow (its token, its secrets, its checks), under the path managed/<name>.yml, from a copy of its source file taken when it was saved. Each has name, path, workflow_name (its `name:`), source (owner/repo/path@ref), source_sha (the commit it was copied at), source_private, mode, repositories, visibility, content (the copy that runs) and when and by whom it was last saved. Owners only.", | |
| 142 | + | ProtectionOp::CreateWorkspaceManagedWorkflow => "Add a workflow to a workspace's repositories. name (lowercase letters, digits and hyphens) makes its path managed/<name>.yml. source is a workflow file in one of the workspace's repositories, owner/repo/.g1t/workflows/file.yml@ref; g1t copies it at the commit ref names, and the copy runs until it is copied again (update_workspace_managed_workflow with refresh). mode is required (a repository's workflow of the same `name:` is set aside, and no one in the repository can turn it off, so its check can be required by a ruleset) or default (a repository's own workflow of the same name runs in its place, and the repository's admins can turn it off). repositories lists names and patterns (* matches any characters, ! excludes), read in order, the last match deciding; empty or left out is every repository. visibility is all (the default), public or private. A source in a private repository runs in private repositories only, so visibility must be private. Owners only. Returns the workflow.", | |
| 143 | + | ProtectionOp::UpdateWorkspaceManagedWorkflow => "Change a workflow a workspace manages: its source, mode, repositories or visibility; fields left out stay as they are. Giving source, or refresh: true, copies the source again at the commit its ref names now. Owners only. Returns the workflow.", | |
| 144 | + | ProtectionOp::DeleteWorkspaceManagedWorkflow => "Take a workflow out of every repository of the workspace. Its runs stay. Owners only.", | |
| 114 | 145 | ProtectionOp::CreateRepositoryDispatch => "Start the default branch's workflows that run `on: repository_dispatch` for event_type (those listing it under types, or with none). client_payload, a JSON object of at most 10 properties and 64 KB, is github.event.client_payload; github.event.action is event_type. A workflow job's own token may send one: with workflow_dispatch, it is how one workflow starts another. Needs the Write role (code:write). Returns how many runs started.", | |
| 115 | 146 | } | |
| 116 | 147 | } | |
| ⋯ | |||
| 124 | 155 | | ProtectionOp::GetForkPrApproval | |
| 125 | 156 | | ProtectionOp::GetActionsAccess | |
| 126 | 157 | | ProtectionOp::GetWorkspaceWorkflowPermissions | |
| 158 | + | | ProtectionOp::ListWorkspaceManagedWorkflows | |
| 127 | 159 | ) | |
| 128 | 160 | } | |
| 129 | 161 | ||
| ⋯ | |||
| 201 | 233 | }), | |
| 202 | 234 | &["repo"], | |
| 203 | 235 | ), | |
| 204 | − | ProtectionOp::GetWorkspaceWorkflowPermissions => (json!({ "workspace": workspace }), &["workspace"]), | |
| 236 | + | ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::ListWorkspaceManagedWorkflows => (json!({ "workspace": workspace }), &["workspace"]), | |
| 237 | + | ProtectionOp::CreateWorkspaceManagedWorkflow => ( | |
| 238 | + | json!({ | |
| 239 | + | "workspace": workspace, | |
| 240 | + | "name": { "type": "string", "description": "Lowercase letters, digits and hyphens: its path is managed/<name>.yml." }, | |
| 241 | + | "source": managed_source(), | |
| 242 | + | "mode": managed_mode(), | |
| 243 | + | "repositories": managed_repositories(), | |
| 244 | + | "visibility": managed_visibility(), | |
| 245 | + | }), | |
| 246 | + | &["workspace", "name", "source", "mode"], | |
| 247 | + | ), | |
| 248 | + | ProtectionOp::UpdateWorkspaceManagedWorkflow => ( | |
| 249 | + | json!({ | |
| 250 | + | "workspace": workspace, | |
| 251 | + | "workflow": managed_name(), | |
| 252 | + | "source": managed_source(), | |
| 253 | + | "mode": managed_mode(), | |
| 254 | + | "repositories": managed_repositories(), | |
| 255 | + | "visibility": managed_visibility(), | |
| 256 | + | "refresh": { "type": "boolean", "description": "Copy the source again, at the commit its ref names now." }, | |
| 257 | + | }), | |
| 258 | + | &["workspace", "workflow"], | |
| 259 | + | ), | |
| 260 | + | ProtectionOp::DeleteWorkspaceManagedWorkflow => (json!({ "workspace": workspace, "workflow": managed_name() }), &["workspace", "workflow"]), | |
| 205 | 261 | ProtectionOp::SetWorkspaceWorkflowPermissions => ( | |
| 206 | 262 | json!({ | |
| 207 | 263 | "workspace": workspace, | |
| ⋯ | |||
| 231 | 287 | } | |
| 232 | 288 | } | |
| 233 | 289 | ||
| 290 | + | fn managed_name() -> Value { | |
| 291 | + | json!({ "type": "string", "description": "The managed workflow's name, as in managed/<name>.yml." }) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | fn managed_source() -> Value { | |
| 295 | + | json!({ "type": "string", "description": "A workflow file in one of the workspace's repositories: owner/repo/.g1t/workflows/file.yml@ref." }) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | fn managed_mode() -> Value { | |
| 299 | + | json!({ "type": "string", "enum": ["required", "default"], "description": "required: a repository cannot take its place or turn it off. default: a repository's workflow of the same name takes its place." }) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | fn managed_repositories() -> Value { | |
| 303 | + | json!({ "type": "array", "items": { "type": "string" }, "description": "Repository names and patterns (* matches any characters, ! excludes); empty is every repository." }) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | fn managed_visibility() -> Value { | |
| 307 | + | json!({ "type": "string", "enum": ["all", "public", "private"], "description": "Which repositories, by visibility. A private source needs private." }) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /// A workspace's managed workflow, in the API's shape. | |
| 311 | + | fn managed_view(workflow: &Value) -> Value { | |
| 312 | + | json!({ | |
| 313 | + | "name": workflow["name"], | |
| 314 | + | "path": workflow["path"], | |
| 315 | + | "workflow_name": workflow["workflowName"], | |
| 316 | + | "source": workflow["source"], | |
| 317 | + | "source_sha": workflow["sourceSha"], | |
| 318 | + | "source_private": workflow["sourcePrivate"], | |
| 319 | + | "mode": workflow["mode"], | |
| 320 | + | "repositories": workflow["repositories"], | |
| 321 | + | "visibility": workflow["visibility"], | |
| 322 | + | "content": workflow["content"], | |
| 323 | + | "created_at": workflow["createdAt"], | |
| 324 | + | "updated_at": workflow["updatedAt"], | |
| 325 | + | "updated_by": workflow["updatedBy"], | |
| 326 | + | }) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /// A workspace's managed workflows: listed, added, changed and removed. | |
| 330 | + | async fn run_managed(op: ProtectionOp, actions: &worker::Fetcher, viewer: &Viewer, actor: g1t_contracts::User, workspace: String, input: &Value) -> Result<Outcome<Value>> { | |
| 331 | + | let strings = |key: &str| -> Option<Vec<String>> { | |
| 332 | + | input.get(key).and_then(Value::as_array).map(|items| items.iter().filter_map(Value::as_str).map(str::to_owned).collect()) | |
| 333 | + | }; | |
| 334 | + | Ok(match op { | |
| 335 | + | ProtectionOp::ListWorkspaceManagedWorkflows => { | |
| 336 | + | let listed: Outcome<Vec<Value>> = g1t_kit::call(actions, "workspace_managed_workflows", &json!({ "viewer": viewer, "workspace": workspace })).await?; | |
| 337 | + | map(listed, |list| json!({ "total_count": list.len(), "managed_workflows": list.iter().map(managed_view).collect::<Vec<_>>() })) | |
| 338 | + | } | |
| 339 | + | ProtectionOp::CreateWorkspaceManagedWorkflow | ProtectionOp::UpdateWorkspaceManagedWorkflow => { | |
| 340 | + | let create = op == ProtectionOp::CreateWorkspaceManagedWorkflow; | |
| 341 | + | let name = if create { text(input, "name") } else { text(input, "workflow") }; | |
| 342 | + | let saved: Outcome<Value> = g1t_kit::call( | |
| 343 | + | actions, | |
| 344 | + | "set_workspace_managed_workflow", | |
| 345 | + | &json!({ | |
| 346 | + | "actor": actor, | |
| 347 | + | "workspace": workspace, | |
| 348 | + | "name": name.unwrap_or_default(), | |
| 349 | + | "create": create, | |
| 350 | + | "source": text(input, "source"), | |
| 351 | + | "mode": text(input, "mode"), | |
| 352 | + | "repositories": strings("repositories"), | |
| 353 | + | "visibility": text(input, "visibility"), | |
| 354 | + | "refresh": flag(input, "refresh").unwrap_or(false), | |
| 355 | + | }), | |
| 356 | + | ) | |
| 357 | + | .await?; | |
| 358 | + | map(saved, |workflow| managed_view(&workflow)) | |
| 359 | + | } | |
| 360 | + | ProtectionOp::DeleteWorkspaceManagedWorkflow => { | |
| 361 | + | let removed: Outcome<bool> = g1t_kit::call( | |
| 362 | + | actions, | |
| 363 | + | "remove_workspace_managed_workflow", | |
| 364 | + | &json!({ "actor": actor, "workspace": workspace, "name": text(input, "workflow").unwrap_or_default() }), | |
| 365 | + | ) | |
| 366 | + | .await?; | |
| 367 | + | map(removed, |removed| json!({ "deleted": removed })) | |
| 368 | + | } | |
| 369 | + | _ => Outcome::fail(FailureCode::Invalid, "Not a managed workflow operation."), | |
| 370 | + | }) | |
| 371 | + | } | |
| 372 | + | ||
| 234 | 373 | fn text(input: &Value, key: &str) -> Option<String> { | |
| 235 | 374 | match &input[key] { | |
| 236 | 375 | Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()), | |
| ⋯ | |||
| 455 | 594 | let Some(workspace) = text(input, "workspace") else { | |
| 456 | 595 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); | |
| 457 | 596 | }; | |
| 597 | + | if op.managed() { | |
| 598 | + | return run_managed(op, actions, viewer, actor(), workspace, input).await; | |
| 599 | + | } | |
| 458 | 600 | let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions { | |
| 459 | 601 | g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await? | |
| 460 | 602 | } else { | |
| ⋯ | |||
| 587 | 729 | map(started, |runs| json!({ "runs": runs })) | |
| 588 | 730 | } | |
| 589 | 731 | // Answered above, before a repository is read. | |
| 590 | − | ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions => { | |
| 591 | − | Outcome::fail(FailureCode::Invalid, "Name the workspace.") | |
| 592 | − | } | |
| 732 | + | ProtectionOp::GetWorkspaceWorkflowPermissions | |
| 733 | + | | ProtectionOp::SetWorkspaceWorkflowPermissions | |
| 734 | + | | ProtectionOp::ListWorkspaceManagedWorkflows | |
| 735 | + | | ProtectionOp::CreateWorkspaceManagedWorkflow | |
| 736 | + | | ProtectionOp::UpdateWorkspaceManagedWorkflow | |
| 737 | + | | ProtectionOp::DeleteWorkspaceManagedWorkflow => Outcome::fail(FailureCode::Invalid, "Name the workspace."), | |
| 593 | 738 | }) | |
| 594 | 739 | } | |
| 595 | 740 | ||
| 12228 | 12228 | }, | |
| 12229 | 12229 | "notes": "A maximum of read holds every repository's default to read-only, and makes the workspace's default read too." | |
| 12230 | 12230 | }, | |
| 12231 | + | "list_workspace_managed_workflows": { | |
| 12232 | + | "params": { | |
| 12233 | + | "workspace": "flagon-io" | |
| 12234 | + | }, | |
| 12235 | + | "response": { | |
| 12236 | + | "total_count": 1, | |
| 12237 | + | "managed_workflows": [ | |
| 12238 | + | { | |
| 12239 | + | "name": "security-scan", | |
| 12240 | + | "path": "managed/security-scan.yml", | |
| 12241 | + | "workflow_name": "Security scan", | |
| 12242 | + | "source": "flagon-io/policies/.g1t/workflows/security-scan.yml@main", | |
| 12243 | + | "source_sha": "4f1c2a9e0b7d3c5a8e6f1b2d4c7a9e0f3b5d8c1a", | |
| 12244 | + | "source_private": false, | |
| 12245 | + | "mode": "required", | |
| 12246 | + | "repositories": [ | |
| 12247 | + | "*", | |
| 12248 | + | "!sandbox-*" | |
| 12249 | + | ], | |
| 12250 | + | "visibility": "all", | |
| 12251 | + | "content": "name: Security scan\non: [push, pull_request]\njobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: ./scripts/scan.sh\n", | |
| 12252 | + | "created_at": "2026-10-12T09:30:00Z", | |
| 12253 | + | "updated_at": "2026-10-12T09:30:00Z", | |
| 12254 | + | "updated_by": "ada" | |
| 12255 | + | } | |
| 12256 | + | ] | |
| 12257 | + | } | |
| 12258 | + | }, | |
| 12259 | + | "create_workspace_managed_workflow": { | |
| 12260 | + | "params": { | |
| 12261 | + | "workspace": "flagon-io" | |
| 12262 | + | }, | |
| 12263 | + | "request": { | |
| 12264 | + | "name": "security-scan", | |
| 12265 | + | "source": "flagon-io/policies/.g1t/workflows/security-scan.yml@main", | |
| 12266 | + | "mode": "required", | |
| 12267 | + | "repositories": [ | |
| 12268 | + | "*", | |
| 12269 | + | "!sandbox-*" | |
| 12270 | + | ] | |
| 12271 | + | }, | |
| 12272 | + | "response": { | |
| 12273 | + | "name": "security-scan", | |
| 12274 | + | "path": "managed/security-scan.yml", | |
| 12275 | + | "workflow_name": "Security scan", | |
| 12276 | + | "source": "flagon-io/policies/.g1t/workflows/security-scan.yml@main", | |
| 12277 | + | "source_sha": "4f1c2a9e0b7d3c5a8e6f1b2d4c7a9e0f3b5d8c1a", | |
| 12278 | + | "source_private": false, | |
| 12279 | + | "mode": "required", | |
| 12280 | + | "repositories": [ | |
| 12281 | + | "*", | |
| 12282 | + | "!sandbox-*" | |
| 12283 | + | ], | |
| 12284 | + | "visibility": "all", | |
| 12285 | + | "content": "name: Security scan\non: [push, pull_request]\njobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: ./scripts/scan.sh\n", | |
| 12286 | + | "created_at": "2026-10-12T09:30:00Z", | |
| 12287 | + | "updated_at": "2026-10-12T09:30:00Z", | |
| 12288 | + | "updated_by": "ada" | |
| 12289 | + | }, | |
| 12290 | + | "notes": "g1t copies the source when you save it, and that copy runs in each repository the workflow targets, as the repository's own workflow, with its token and secrets. A source in a private repository runs in private repositories only: give visibility private." | |
| 12291 | + | }, | |
| 12292 | + | "update_workspace_managed_workflow": { | |
| 12293 | + | "params": { | |
| 12294 | + | "workspace": "flagon-io", | |
| 12295 | + | "workflow": "security-scan" | |
| 12296 | + | }, | |
| 12297 | + | "request": { | |
| 12298 | + | "refresh": true | |
| 12299 | + | }, | |
| 12300 | + | "response": { | |
| 12301 | + | "name": "security-scan", | |
| 12302 | + | "path": "managed/security-scan.yml", | |
| 12303 | + | "workflow_name": "Security scan", | |
| 12304 | + | "source": "flagon-io/policies/.g1t/workflows/security-scan.yml@main", | |
| 12305 | + | "source_sha": "4f1c2a9e0b7d3c5a8e6f1b2d4c7a9e0f3b5d8c1a", | |
| 12306 | + | "source_private": false, | |
| 12307 | + | "mode": "required", | |
| 12308 | + | "repositories": [ | |
| 12309 | + | "*", | |
| 12310 | + | "!sandbox-*" | |
| 12311 | + | ], | |
| 12312 | + | "visibility": "all", | |
| 12313 | + | "content": "name: Security scan\non: [push, pull_request]\njobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: ./scripts/scan.sh\n", | |
| 12314 | + | "created_at": "2026-10-12T09:30:00Z", | |
| 12315 | + | "updated_at": "2026-10-12T09:30:00Z", | |
| 12316 | + | "updated_by": "ada" | |
| 12317 | + | }, | |
| 12318 | + | "notes": "refresh copies the source again at the commit its ref names now. Fields left out stay as they are." | |
| 12319 | + | }, | |
| 12320 | + | "delete_workspace_managed_workflow": { | |
| 12321 | + | "params": { | |
| 12322 | + | "workspace": "flagon-io", | |
| 12323 | + | "workflow": "security-scan" | |
| 12324 | + | }, | |
| 12325 | + | "response": { | |
| 12326 | + | "deleted": true | |
| 12327 | + | } | |
| 12328 | + | }, | |
| 12231 | 12329 | "get_token_policy": { | |
| 12232 | 12330 | "params": { | |
| 12233 | 12331 | "workspace": "flagon-io" |
| 426 | 426 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 427 | 427 | &[], | |
| 428 | 428 | ), | |
| 429 | + | route( | |
| 430 | + | "GET", | |
| 431 | + | "/workspaces/:workspace/actions/managed-workflows", | |
| 432 | + | Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), | |
| 433 | + | &[], | |
| 434 | + | ), | |
| 435 | + | route( | |
| 436 | + | "POST", | |
| 437 | + | "/workspaces/:workspace/actions/managed-workflows", | |
| 438 | + | Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), | |
| 439 | + | &[], | |
| 440 | + | ), | |
| 441 | + | route( | |
| 442 | + | "PATCH", | |
| 443 | + | "/workspaces/:workspace/actions/managed-workflows/:workflow", | |
| 444 | + | Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), | |
| 445 | + | &[], | |
| 446 | + | ), | |
| 447 | + | route( | |
| 448 | + | "DELETE", | |
| 449 | + | "/workspaces/:workspace/actions/managed-workflows/:workflow", | |
| 450 | + | Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), | |
| 451 | + | &[], | |
| 452 | + | ), | |
| 429 | 453 | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 430 | 454 | route( | |
| 431 | 455 | "POST", |
| 230 | 230 | Tool { | |
| 231 | 231 | name: "workflow", | |
| 232 | 232 | title: "Workflows", | |
| 233 | − | description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own; environments' protection rules, approving or rejecting the jobs they hold, approving a pull request's run from outside, the token's default permissions and repository dispatch. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.", | |
| 233 | + | description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own; environments' protection rules, approving or rejecting the jobs they hold, approving a pull request's run from outside, the token's default permissions, repository dispatch, and the workflows a workspace runs in its repositories without a file in them. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.", | |
| 234 | 234 | default_action: None, | |
| 235 | 235 | actions: &[ | |
| 236 | 236 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| ⋯ | |||
| 282 | 282 | a("repository_dispatch", Op::Protection(ProtectionOp::CreateRepositoryDispatch), "Start repository_dispatch workflows with an event"), | |
| 283 | 283 | a("get_workspace_permissions", Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), "A workspace's default and maximum token permissions"), | |
| 284 | 284 | a("set_workspace_permissions", Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), "Set them, and whether jobs may open pull requests"), | |
| 285 | + | a("list_managed_workflows", Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), "The workflows a workspace runs in its repositories"), | |
| 286 | + | a("add_managed_workflow", Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), "Run a workflow in the workspace's repositories, required or default"), | |
| 287 | + | a("update_managed_workflow", Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), "Change one's repositories or mode, or copy its source again"), | |
| 288 | + | a("remove_managed_workflow", Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), "Take one out of the workspace's repositories"), | |
| 285 | 289 | a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"), | |
| 286 | 290 | a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"), | |
| 287 | 291 | a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"), | |
| 180 | 180 | A workflow of the same `name:` in `.g1t/workflows/` runs in its place, so | |
| 181 | 181 | to change one, commit your own version of it. | |
| 182 | 182 | ||
| 183 | + | ## Workflows a workspace manages | |
| 184 | + | ||
| 185 | + | A workspace's owners can run a workflow in the workspace's repositories | |
| 186 | + | without committing it to each one, as GitHub's required workflows do. It | |
| 187 | + | runs in each repository it targets as that repository's own workflow: on | |
| 188 | + | the repository's events, with the repository's job token, secrets and | |
| 189 | + | variables, and reporting the repository's checks. Its path is | |
| 190 | + | `managed/<name>.yml`, and the repository's **Actions** page lists it as | |
| 191 | + | managed by the workspace, with the file it came from. | |
| 192 | + | ||
| 193 | + | To add one: | |
| 194 | + | ||
| 195 | + | 1. Commit the workflow to one of the workspace's repositories, such as | |
| 196 | + | `acme/policies/.g1t/workflows/scan.yml`. | |
| 197 | + | 2. Open **Workspace → Code → Actions**, `g1t.sh/<workspace>/-/actions`, and | |
| 198 | + | under **Workflows this workspace manages** choose **Add workflow**. | |
| 199 | + | 3. Give it a name, its source as `owner/repo/path@ref` (for example | |
| 200 | + | `acme/policies/.g1t/workflows/scan.yml@main`), a mode, and the | |
| 201 | + | repositories it runs in, then choose **Add workflow**. | |
| 202 | + | ||
| 203 | + | | Setting | Options | | |
| 204 | + | | --- | --- | | |
| 205 | + | | Mode | **Required**: it runs in every repository it targets. A workflow of the same `name:` in the repository does not run while it is required, and nobody in the repository can turn it off. **Default**: it runs where the repository has no workflow of the same `name:`, which runs in its place, and the repository's admins can turn it off on its Actions page. | | |
| 206 | + | | Repositories | **All repositories**, or names and patterns, one per line: `*` matches any characters, and `!name` leaves out what an earlier line took in. The last line that matches decides, without regard to case; only `!` lines means every repository but those. | | |
| 207 | + | | Visibility | Public and private repositories, public only, or private only. | | |
| 208 | + | ||
| 209 | + | g1t copies the source file when you save, at the commit its ref names | |
| 210 | + | then, and that copy runs until you copy it again with **Copy again** (or | |
| 211 | + | save it with another source). So someone who can push to the source | |
| 212 | + | repository cannot change what runs with every repository's secrets: only | |
| 213 | + | an owner can, by copying it. The workflows list shows the commit each was | |
| 214 | + | copied at. | |
| 215 | + | ||
| 216 | + | - The source must be a workflow file in one of the workspace's own | |
| 217 | + | repositories. | |
| 218 | + | - A source in a private repository runs in private repositories only, as a | |
| 219 | + | public repository's workflows cannot use a private repository's: choose | |
| 220 | + | **Private repositories only**, or g1t refuses it. A repository made | |
| 221 | + | public later stops running it. | |
| 222 | + | - `uses: ./…` in the copy names the repository it runs in, as it would in a | |
| 223 | + | file committed there. To call a reusable workflow of the source | |
| 224 | + | repository, name it in full: | |
| 225 | + | `uses: acme/policies/.g1t/workflows/build.yml@main`, which follows the | |
| 226 | + | [rules for other repositories](#actions-and-workflows-from-other-repositories). | |
| 227 | + | - A required workflow runs for pull requests from forks and in the | |
| 228 | + | [merge queue](/guides/merge-queue/) too, whatever the fork's files say. | |
| 229 | + | - When it changes, up to 20 repositories' workflow lists are brought up to | |
| 230 | + | date at once. In a larger workspace the rest follow within minutes, or | |
| 231 | + | as soon as someone opens their Actions page; their runs read the | |
| 232 | + | workflows as they are meanwhile. | |
| 233 | + | ||
| 234 | + | To make a pull request wait for a required workflow, require its check in | |
| 235 | + | a [ruleset](/guides/rules/): its `name:` as the context, with | |
| 236 | + | `integration: actions`. A failure of the workflow fails the check whatever | |
| 237 | + | else reports a check of that name. | |
| 238 | + | ||
| 239 | + | Through the API and MCP: | |
| 240 | + | ||
| 241 | + | | Route | MCP | | | |
| 242 | + | | --- | --- | --- | | |
| 243 | + | | [`GET /workspaces/{workspace}/actions/managed-workflows`](/reference/api/run-protection/list-workspace-managed-workflows/) | `workflow` `list_managed_workflows` | Each with its source, the commit it was copied at, its mode, repositories, visibility and the copy that runs. | | |
| 244 | + | | [`POST /workspaces/{workspace}/actions/managed-workflows`](/reference/api/run-protection/create-workspace-managed-workflow/) | `workflow` `add_managed_workflow` | `name`, `source`, `mode` (`required` or `default`), `repositories`, `visibility` (`all`, `public` or `private`). | | |
| 245 | + | | [`PATCH /workspaces/{workspace}/actions/managed-workflows/{workflow}`](/reference/api/run-protection/update-workspace-managed-workflow/) | `workflow` `update_managed_workflow` | Any of those; `refresh: true` copies the source again. | | |
| 246 | + | | [`DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`](/reference/api/run-protection/delete-workspace-managed-workflow/) | `workflow` `remove_managed_workflow` | Its runs stay. | | |
| 247 | + | ||
| 248 | + | Only owners can see and change them; a token needs `workspace:read` to | |
| 249 | + | list them and `workspace:admin` to change them. | |
| 250 | + | ||
| 183 | 251 | ## Schedules | |
| 184 | 252 | ||
| 185 | 253 | A workflow with `on: schedule` runs on the default branch's latest commit, |
| 486 | 486 | sets any of them (`workspace` `update` over MCP). Each change is in the | |
| 487 | 487 | [audit log](/guides/audit-log/) as `workspace.member_privileges_changed`. | |
| 488 | 488 | ||
| 489 | + | ## Workflows the workspace manages | |
| 490 | + | ||
| 491 | + | An owner can run a workflow in every repository of the workspace, or in | |
| 492 | + | those whose names match, without committing it to each: a security scan, a | |
| 493 | + | licence check, a release policy. Add it under **Workspace → Code → | |
| 494 | + | Actions**, `g1t.sh/<workspace>/-/actions`, from a workflow file in one of | |
| 495 | + | the workspace's repositories, as **Required** (repositories cannot take its | |
| 496 | + | place or turn it off, so a ruleset can require its check) or **Default** | |
| 497 | + | (a repository's own workflow of the same name runs instead, and its admins | |
| 498 | + | can turn it off). Each repository's Actions page lists it as managed by the | |
| 499 | + | workspace. [Workflows a workspace manages](/guides/actions/#workflows-a-workspace-manages) | |
| 500 | + | has the details, and the API. | |
| 501 | + | ||
| 489 | 502 | ## Require two-factor authentication | |
| 490 | 503 | ||
| 491 | 504 | An owner can require everyone with access to the workspace, its members | |
| ⋯ | |||
| 718 | 731 | | | **Billing and plans** | Members | The plan, the spend limit, prepaying and every charge. `-/billing` | | |
| 719 | 732 | | **Compute** | [**Runners**](/guides/self-hosted-runners/) | Owners | The workspace's self-hosted machines, their groups and registration tokens. `-/runners` | | |
| 720 | 733 | | **Code** | **Repositories** | Members | Every repository, and for owners **Recently deleted**, where a [deleted repository](/guides/managing-repositories/#restore-a-repository) can be restored, or purged, for 30 days. `-/repositories` | | |
| 721 | − | | | **Actions** | Members | How workflows run in the workspace. `-/actions` | | |
| 734 | + | | | **Actions** | Members | How workflows run in the workspace: what jobs' tokens may do, and for owners the [workflows the workspace manages](#workflows-the-workspace-manages) in its repositories. `-/actions` | | |
| 722 | 735 | | | [**Secrets and variables**](/guides/secrets-and-variables/) | Members | What runs and deployments are given. Owners change them. `-/secrets` | | |
| 723 | 736 | | | [**Webhooks**](/guides/webhooks/) | Members | Where the workspace's events are sent. Owners add and change them. `-/webhooks` | | |
| 724 | 737 | | | **Deployments**, **Project defaults** | | Soon: defaults for every project's deployments, and what a new project starts with. | | |
| 476 | 476 | | [`set_permissions`](/reference/api/run-protection/set-workflow-permissions/) | Set `default_workflow_permissions` (`read`, `write` or `inherit`) and `can_approve_pull_request_reviews`. Admin role. | `repo` | `repo:admin` | | |
| 477 | 477 | | [`get_workspace_permissions`](/reference/api/run-protection/get-workspace-workflow-permissions/) | A workspace's default for new repositories, its maximum, and whether its repositories may let jobs open and approve pull requests. Members. | `workspace` | `workspace:read` | | |
| 478 | 478 | | [`set_workspace_permissions`](/reference/api/run-protection/set-workspace-workflow-permissions/) | Change them: `default_workflow_permissions`, `max_workflow_permissions`, `can_approve_pull_request_reviews`. Owners. | `workspace` | `workspace:admin` | | |
| 479 | + | | [`list_managed_workflows`](/reference/api/run-protection/list-workspace-managed-workflows/) | The [workflows a workspace manages](/guides/actions/#workflows-a-workspace-manages) in its repositories, each with its source, the commit it was copied at, its `mode`, `repositories` and `visibility`, and the copy that runs. Owners. | `workspace` | `workspace:read` | | |
| 480 | + | | [`add_managed_workflow`](/reference/api/run-protection/create-workspace-managed-workflow/) | Run a workflow file of one of the workspace's repositories (`source`, `owner/repo/.g1t/workflows/file.yml@ref`, copied now) in its repositories as `managed/<name>.yml`: `mode` `required` or `default`, `repositories` (names and patterns, `!` excludes; empty for all) and `visibility` (`all`, `public` or `private`; a private source needs `private`). Owners. | `workspace`, `name`, `source`, `mode` | `workspace:admin` | | |
| 481 | + | | [`update_managed_workflow`](/reference/api/run-protection/update-workspace-managed-workflow/) | Change one's `source`, `mode`, `repositories` or `visibility`; `refresh` copies its source again. Owners. | `workspace`, `workflow` | `workspace:admin` | | |
| 482 | + | | [`remove_managed_workflow`](/reference/api/run-protection/delete-workspace-managed-workflow/) | Take one out of every repository; its runs stay. Owners. | `workspace`, `workflow` | `workspace:admin` | | |
| 479 | 483 | | [`get_approval_policy`](/reference/api/run-protection/get-fork-pr-approval/) | Which pull requests' runs wait for approval. | `repo` | `repo:read` | | |
| 480 | 484 | | [`set_approval_policy`](/reference/api/run-protection/set-fork-pr-approval/) | Set `approval_policy`: `first_time_contributors`, `outside_contributors` or `all_external_contributors`. Admin role. | `repo`, `approval_policy` | `repo:admin` | | |
| 481 | 485 | | [`get_access`](/reference/api/run-protection/get-actions-access/) | Who may use this private repository's actions and reusable workflows: `access_level` `none` (only itself) or `organization` (the workspace's private repositories). | `repo` | `repo:read` | |
Binary or large file; its contents are not shown.
| 9 | 9 | import { Duration, Notes, StatusIcon, shortRef } from "../../components/actions"; | |
| 10 | 10 | import { AddCiPrompt } from "../../components/add-ci"; | |
| 11 | 11 | import { ComputeNote, CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 12 | + | import { Badge } from "../../components/ui/badge"; | |
| 12 | 13 | import { Button } from "../../components/ui/button"; | |
| 13 | 14 | import { Card } from "../../components/ui/card"; | |
| 14 | 15 | import { useWorkflowReason } from "../../components/mirror"; | |
| ⋯ | |||
| 304 | 305 | <div className="min-w-0"> | |
| 305 | 306 | <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3> | |
| 306 | 307 | {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> | |
| 308 | + | <ManagedLine workflow={workflow} /> | |
| 311 | 309 | ) : ( | |
| 312 | 310 | <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg"> | |
| 313 | 311 | <FileCode2 size={12} /> | |
| ⋯ | |||
| 322 | 320 | <div className="flex flex-wrap items-center gap-2"> | |
| 323 | 321 | <StatusBadge workflow={workflow} repo={base.slice(1)} /> | |
| 324 | 322 | {member && workflow.dispatch && workflow.state === "active" && <RunWorkflow workflow={workflow} />} | |
| 325 | − | {member && manage && <Form method="post"> | |
| 323 | + | {member && manage && workflow.managedBy?.mode !== "required" && <Form method="post"> | |
| 326 | 324 | <input type="hidden" name="intent" value="toggle" /> | |
| 327 | 325 | <input type="hidden" name="workflow" value={workflow.id} /> | |
| 328 | 326 | <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} /> | |
| ⋯ | |||
| 346 | 344 | ); | |
| 347 | 345 | } | |
| 348 | 346 | ||
| 347 | + | /** Who manages a managed workflow, and what a file of its name does. */ | |
| 348 | + | function ManagedLine({ workflow }: { workflow: Workflow }) { | |
| 349 | + | const by = workflow.managedBy; | |
| 350 | + | const origin = by?.origin ?? "g1t"; | |
| 351 | + | const what = | |
| 352 | + | origin === "g1t" | |
| 353 | + | ? `it runs here without a file in the repository. A workflow named ${workflow.name} in .g1t/workflows/ takes its place.` | |
| 354 | + | : by?.mode === "required" | |
| 355 | + | ? `required in this repository by ${origin}'s owners. It runs here without a file in the repository; a workflow named ${workflow.name} in .g1t/workflows/ does not run while it is required, and it cannot be turned off here.` | |
| 356 | + | : `it runs here without a file in the repository. A workflow named ${workflow.name} in .g1t/workflows/ takes its place, and an admin can turn it off here.`; | |
| 357 | + | return ( | |
| 358 | + | <span className="block text-xs text-faint"> | |
| 359 | + | <span className="inline-flex items-center gap-1"> | |
| 360 | + | <BadgeCheck size={12} className="shrink-0" /> | |
| 361 | + | <span> | |
| 362 | + | Managed by {origin}: {what} | |
| 363 | + | </span> | |
| 364 | + | </span> | |
| 365 | + | {by?.source && <span className="mt-0.5 block font-mono">From {by.source}</span>} | |
| 366 | + | </span> | |
| 367 | + | ); | |
| 368 | + | } | |
| 369 | + | ||
| 349 | 370 | const EXAMPLE = `name: CI | |
| 350 | 371 | on: | |
| 351 | 372 | push: | |
| ⋯ | |||
| 434 | 455 | <span className="block truncate font-mono text-[0.6875rem] text-faint">{w.path}</span> | |
| 435 | 456 | )} | |
| 436 | 457 | </span> | |
| 458 | + | {w.managedBy && w.managedBy.origin !== "g1t" && ( | |
| 459 | + | <Badge tone={w.managedBy.mode === "required" ? "accent" : "neutral"} className="ml-auto"> | |
| 460 | + | {w.managedBy.origin} | |
| 461 | + | </Badge> | |
| 462 | + | )} | |
| 437 | 463 | {w.error && <AlertTriangle size={12} className="shrink-0 text-danger" aria-label="Its file has a problem" />} | |
| 438 | 464 | {w.state === "disabled" && <span className="ml-auto shrink-0 text-xs text-faint">off</span>} | |
| 439 | 465 | </Link> | |
| 1 | − | import { Form } from "react-router"; | |
| 1 | + | import { BadgeCheck, Plus } from "lucide-react"; | |
| 2 | + | import { useEffect, useState } from "react"; | |
| 3 | + | import { Form, useFetcher } from "react-router"; | |
| 2 | 4 | ||
| 3 | − | import type { WorkspaceActionsSettings } from "@g1t/contracts"; | |
| 5 | + | import type { WorkspaceActionsSettings, WorkspaceManagedWorkflow } from "@g1t/contracts"; | |
| 4 | 6 | ||
| 5 | 7 | import type { Route } from "./+types/actions-settings"; | |
| 6 | 8 | import { SettingsSection as Section } from "../../components/settings-section"; | |
| 7 | − | import { ErrorText, SubmitButton } from "../../components/ui"; | |
| 9 | + | import { EmptyState, ErrorText, Field, Input, SubmitButton, Textarea, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { | |
| 11 | + | AlertDialog, | |
| 12 | + | AlertDialogCancel, | |
| 13 | + | AlertDialogContent, | |
| 14 | + | AlertDialogDescription, | |
| 15 | + | AlertDialogFooter, | |
| 16 | + | AlertDialogHeader, | |
| 17 | + | AlertDialogTitle, | |
| 18 | + | AlertDialogTrigger, | |
| 19 | + | } from "../../components/ui/alert-dialog"; | |
| 20 | + | import { Badge } from "../../components/ui/badge"; | |
| 21 | + | import { Button } from "../../components/ui/button"; | |
| 22 | + | import { Card } from "../../components/ui/card"; | |
| 8 | 23 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 24 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog"; | |
| 9 | 25 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 26 | + | import { SelectField } from "../../components/ui/select"; | |
| 10 | 27 | import { page } from "../../lib/meta"; | |
| 11 | 28 | import { actions } from "../../lib/services.server"; | |
| 12 | 29 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| ⋯ | |||
| 20 | 37 | // Members may see the policy; owners change it. | |
| 21 | 38 | const role = roleIn(viewer, params.owner); | |
| 22 | 39 | if (!role) throw new Response(null, { status: 404 }); | |
| 23 | − | const settings = unwrap(await actions.workspaceActionsSettings(params.owner.toLowerCase(), viewer)); | |
| 24 | − | return { settings, owner: role === "owner" }; | |
| 40 | + | const workspace = params.owner.toLowerCase(); | |
| 41 | + | const settings = unwrap(await actions.workspaceActionsSettings(workspace, viewer)); | |
| 42 | + | // The workflows it manages, and their sources, are its owners' to see. | |
| 43 | + | const managed = role === "owner" ? unwrap(await actions.workspaceManagedWorkflows(workspace, viewer)) : []; | |
| 44 | + | return { settings, managed, owner: role === "owner" }; | |
| 25 | 45 | } | |
| 26 | 46 | ||
| 47 | + | /** One per line, or separated by commas. */ | |
| 48 | + | function patterns(text: string): string[] { | |
| 49 | + | return text | |
| 50 | + | .split(/[\n,]/) | |
| 51 | + | .map((line) => line.trim()) | |
| 52 | + | .filter(Boolean); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | type ManagedResult = { ok: true; error: null } | { ok: false; error: string }; | |
| 56 | + | ||
| 57 | + | async function managedAction(form: FormData, user: Parameters<typeof actions.removeWorkspaceManagedWorkflow>[0], workspace: string): Promise<ManagedResult> { | |
| 58 | + | const intent = form.get("intent"); | |
| 59 | + | const name = String(form.get("name") ?? "").trim(); | |
| 60 | + | const done = (result: { ok: true } | { ok: false; error: { message: string } }): ManagedResult => | |
| 61 | + | result.ok ? { ok: true, error: null } : { ok: false, error: result.error.message }; | |
| 62 | + | if (intent === "managed-remove") return done(await actions.removeWorkspaceManagedWorkflow(user, workspace, name)); | |
| 63 | + | if (intent === "managed-refresh") return done(await actions.setWorkspaceManagedWorkflow(user, workspace, name, { refresh: true })); | |
| 64 | + | const mode = form.get("mode") === "required" ? "required" : "default"; | |
| 65 | + | const visibility = String(form.get("visibility") ?? "all") as "all" | "public" | "private"; | |
| 66 | + | const change = { | |
| 67 | + | source: String(form.get("source") ?? "").trim() || undefined, | |
| 68 | + | mode, | |
| 69 | + | repositories: form.get("target") === "all" ? [] : patterns(String(form.get("repositories") ?? "")), | |
| 70 | + | visibility, | |
| 71 | + | } as const; | |
| 72 | + | return done(await actions.setWorkspaceManagedWorkflow(user, workspace, name, change, form.get("create") === "true")); | |
| 73 | + | } | |
| 74 | + | ||
| 27 | 75 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 28 | 76 | assertSameOrigin(request); | |
| 29 | 77 | if (roleIn(getViewer(context), params.owner) !== "owner") throw new Response(null, { status: 404 }); | |
| 30 | 78 | const user = requireUser(context, request); | |
| 31 | 79 | const form = await request.formData(); | |
| 80 | + | if (String(form.get("intent") ?? "").startsWith("managed-")) { | |
| 81 | + | return { saved: false, error: null, managed: await managedAction(form, user, params.owner.toLowerCase()) }; | |
| 82 | + | } | |
| 32 | 83 | const level = (name: string): "read" | "write" => (form.get(name) === "write" ? "write" : "read"); | |
| 33 | 84 | const change: Partial<WorkspaceActionsSettings> = { | |
| 34 | 85 | defaultPermissions: level("defaultPermissions"), | |
| ⋯ | |||
| 39 | 90 | return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message }; | |
| 40 | 91 | } | |
| 41 | 92 | ||
| 42 | − | export default function WorkspaceActionsSettingsPage({ loaderData, actionData }: Route.ComponentProps) { | |
| 43 | − | const { settings, owner } = loaderData; | |
| 93 | + | /** Where a workflow runs, in words. */ | |
| 94 | + | function targetWords(workflow: WorkspaceManagedWorkflow): string { | |
| 95 | + | const which = workflow.repositories.length === 0 ? "Every repository" : workflow.repositories.join(", "); | |
| 96 | + | if (workflow.visibility === "public") return `${which}, public ones only`; | |
| 97 | + | if (workflow.visibility === "private") return `${which}, private ones only`; | |
| 98 | + | return which; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /** The form that adds a managed workflow, or changes one. */ | |
| 102 | + | function ManagedWorkflowDialog({ workspace, workflow }: { workspace: string; workflow?: WorkspaceManagedWorkflow }) { | |
| 103 | + | const [open, setOpen] = useState(false); | |
| 104 | + | const fetcher = useFetcher<{ managed?: ManagedResult }>(); | |
| 105 | + | const result = fetcher.data?.managed; | |
| 106 | + | const [target, setTarget] = useState(workflow && workflow.repositories.length > 0 ? "selected" : "all"); | |
| 107 | + | useEffect(() => { | |
| 108 | + | if (fetcher.state === "idle" && result?.ok) setOpen(false); | |
| 109 | + | }, [fetcher.state, result]); | |
| 44 | 110 | return ( | |
| 111 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 112 | + | <DialogTrigger asChild> | |
| 113 | + | {workflow ? ( | |
| 114 | + | <Button variant="outline" size="sm"> | |
| 115 | + | Edit | |
| 116 | + | </Button> | |
| 117 | + | ) : ( | |
| 118 | + | <Button variant="outline"> | |
| 119 | + | <Plus size={14} /> | |
| 120 | + | Add workflow | |
| 121 | + | </Button> | |
| 122 | + | )} | |
| 123 | + | </DialogTrigger> | |
| 124 | + | <DialogContent> | |
| 125 | + | <DialogHeader> | |
| 126 | + | <DialogTitle>{workflow ? `Edit ${workflow.workflowName}` : "Add a workflow"}</DialogTitle> | |
| 127 | + | <DialogDescription> | |
| 128 | + | It runs in each repository it targets as that repository's own workflow, with its token and secrets. g1t copies | |
| 129 | + | the file when you save, and that copy runs until you copy it again. | |
| 130 | + | </DialogDescription> | |
| 131 | + | </DialogHeader> | |
| 132 | + | <fetcher.Form method="post" className="space-y-4"> | |
| 133 | + | <input type="hidden" name="intent" value="managed-save" /> | |
| 134 | + | <input type="hidden" name="create" value={workflow ? "false" : "true"} /> | |
| 135 | + | {workflow ? ( | |
| 136 | + | <input type="hidden" name="name" value={workflow.name} /> | |
| 137 | + | ) : ( | |
| 138 | + | <Field label="Name" hint="Lowercase letters, digits and hyphens. It runs as managed/<name>.yml."> | |
| 139 | + | <Input name="name" required pattern="[a-z0-9-]{1,64}" placeholder="security-scan" /> | |
| 140 | + | </Field> | |
| 141 | + | )} | |
| 142 | + | <Field label="Source" hint={`A workflow file in one of ${workspace}'s repositories, at a branch, tag or commit.`}> | |
| 143 | + | <Input | |
| 144 | + | name="source" | |
| 145 | + | required | |
| 146 | + | defaultValue={workflow?.source} | |
| 147 | + | placeholder={`${workspace}/policies/.g1t/workflows/scan.yml@main`} | |
| 148 | + | /> | |
| 149 | + | </Field> | |
| 150 | + | <div className="space-y-2"> | |
| 151 | + | <p className="text-sm font-medium text-muted">Mode</p> | |
| 152 | + | <RadioGroup name="mode" defaultValue={workflow?.mode ?? "required"} className="gap-3"> | |
| 153 | + | <RadioOption | |
| 154 | + | value="required" | |
| 155 | + | label="Required" | |
| 156 | + | description="Runs in every repository it targets. A repository's workflow of the same name does not run in its place, and no one can turn it off there, so a ruleset can require its check." | |
| 157 | + | /> | |
| 158 | + | <RadioOption | |
| 159 | + | value="default" | |
| 160 | + | label="Default" | |
| 161 | + | description="Runs where the repository has no workflow of the same name. A repository's admins can turn it off." | |
| 162 | + | /> | |
| 163 | + | </RadioGroup> | |
| 164 | + | </div> | |
| 165 | + | <div className="space-y-2"> | |
| 166 | + | <p className="text-sm font-medium text-muted">Repositories</p> | |
| 167 | + | <RadioGroup name="target" value={target} onValueChange={setTarget} className="gap-3"> | |
| 168 | + | <RadioOption value="all" label="All repositories" /> | |
| 169 | + | <RadioOption value="selected" label="Repositories that match" description="Names or patterns, one per line. * matches any characters; !name leaves one out." /> | |
| 170 | + | </RadioGroup> | |
| 171 | + | {target === "selected" && ( | |
| 172 | + | <Textarea | |
| 173 | + | name="repositories" | |
| 174 | + | rows={4} | |
| 175 | + | aria-label="Repository names and patterns" | |
| 176 | + | defaultValue={workflow?.repositories.join("\n")} | |
| 177 | + | placeholder={"web\napi-*\n!api-sandbox"} | |
| 178 | + | /> | |
| 179 | + | )} | |
| 180 | + | </div> | |
| 181 | + | <div className="space-y-2"> | |
| 182 | + | <p className="text-sm font-medium text-muted">Visibility</p> | |
| 183 | + | <SelectField | |
| 184 | + | name="visibility" | |
| 185 | + | defaultValue={workflow?.visibility ?? "all"} | |
| 186 | + | aria-label="Visibility" | |
| 187 | + | options={[ | |
| 188 | + | { value: "all", label: "Public and private repositories" }, | |
| 189 | + | { value: "public", label: "Public repositories only" }, | |
| 190 | + | { value: "private", label: "Private repositories only" }, | |
| 191 | + | ]} | |
| 192 | + | /> | |
| 193 | + | <p className="text-xs text-faint">A source in a private repository runs in private repositories only.</p> | |
| 194 | + | </div> | |
| 195 | + | <ErrorText>{result && !result.ok ? result.error : null}</ErrorText> | |
| 196 | + | <DialogFooter> | |
| 197 | + | <SubmitButton fetcher={fetcher} pending="Saving…"> | |
| 198 | + | {workflow ? "Save" : "Add workflow"} | |
| 199 | + | </SubmitButton> | |
| 200 | + | </DialogFooter> | |
| 201 | + | </fetcher.Form> | |
| 202 | + | </DialogContent> | |
| 203 | + | </Dialog> | |
| 204 | + | ); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | function ManagedWorkflowCard({ workspace, workflow }: { workspace: string; workflow: WorkspaceManagedWorkflow }) { | |
| 208 | + | const fetcher = useFetcher<{ managed?: ManagedResult }>(); | |
| 209 | + | const result = fetcher.data?.managed; | |
| 210 | + | return ( | |
| 211 | + | <li className="space-y-2 p-4"> | |
| 212 | + | <div className="flex flex-wrap items-start justify-between gap-3"> | |
| 213 | + | <div className="min-w-0"> | |
| 214 | + | <p className="flex flex-wrap items-center gap-2 font-medium"> | |
| 215 | + | {workflow.workflowName} | |
| 216 | + | <Badge tone={workflow.mode === "required" ? "accent" : "neutral"}>{workflow.mode === "required" ? "Required" : "Default"}</Badge> | |
| 217 | + | </p> | |
| 218 | + | <p className="mt-0.5 font-mono text-xs break-all text-faint">{workflow.path}</p> | |
| 219 | + | </div> | |
| 220 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 221 | + | <ManagedWorkflowDialog workspace={workspace} workflow={workflow} /> | |
| 222 | + | <fetcher.Form method="post"> | |
| 223 | + | <input type="hidden" name="intent" value="managed-refresh" /> | |
| 224 | + | <input type="hidden" name="name" value={workflow.name} /> | |
| 225 | + | <SubmitButton variant="outline" size="sm" fetcher={fetcher} pending="Copying…"> | |
| 226 | + | Copy again | |
| 227 | + | </SubmitButton> | |
| 228 | + | </fetcher.Form> | |
| 229 | + | <AlertDialog> | |
| 230 | + | <AlertDialogTrigger asChild> | |
| 231 | + | <Button variant="outline" size="sm"> | |
| 232 | + | Remove | |
| 233 | + | </Button> | |
| 234 | + | </AlertDialogTrigger> | |
| 235 | + | <AlertDialogContent> | |
| 236 | + | <fetcher.Form method="post"> | |
| 237 | + | <input type="hidden" name="intent" value="managed-remove" /> | |
| 238 | + | <input type="hidden" name="name" value={workflow.name} /> | |
| 239 | + | <AlertDialogHeader> | |
| 240 | + | <AlertDialogTitle>Remove {workflow.workflowName}?</AlertDialogTitle> | |
| 241 | + | <AlertDialogDescription> | |
| 242 | + | It stops running in {workspace}'s repositories. Its runs stay. | |
| 243 | + | </AlertDialogDescription> | |
| 244 | + | </AlertDialogHeader> | |
| 245 | + | <AlertDialogFooter> | |
| 246 | + | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 247 | + | <SubmitButton variant="destructive" fetcher={fetcher} pending="Removing…"> | |
| 248 | + | Remove | |
| 249 | + | </SubmitButton> | |
| 250 | + | </AlertDialogFooter> | |
| 251 | + | </fetcher.Form> | |
| 252 | + | </AlertDialogContent> | |
| 253 | + | </AlertDialog> | |
| 254 | + | </div> | |
| 255 | + | </div> | |
| 256 | + | <dl className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[7rem_1fr]"> | |
| 257 | + | <dt className="text-muted">From</dt> | |
| 258 | + | <dd className="min-w-0 font-mono text-xs break-all"> | |
| 259 | + | {workflow.source} | |
| 260 | + | {workflow.sourceSha && <span className="text-faint"> at {workflow.sourceSha.slice(0, 7)}</span>} | |
| 261 | + | </dd> | |
| 262 | + | <dt className="text-muted">Runs in</dt> | |
| 263 | + | <dd>{targetWords(workflow)}</dd> | |
| 264 | + | <dt className="text-muted">Copied</dt> | |
| 265 | + | <dd className="text-muted"> | |
| 266 | + | <TimeAgo at={workflow.updatedAt} /> by {workflow.updatedBy} | |
| 267 | + | </dd> | |
| 268 | + | </dl> | |
| 269 | + | <ErrorText>{result && !result.ok ? result.error : null}</ErrorText> | |
| 270 | + | </li> | |
| 271 | + | ); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | function ManagedWorkflows({ workspace, managed }: { workspace: string; managed: WorkspaceManagedWorkflow[] }) { | |
| 275 | + | return ( | |
| 276 | + | <section className="max-w-4xl space-y-4 border-t border-line pt-8"> | |
| 277 | + | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 278 | + | <div> | |
| 279 | + | <h2 className="flex items-center gap-2 font-medium"> | |
| 280 | + | <BadgeCheck size={16} className="text-accent" /> | |
| 281 | + | Workflows this workspace manages | |
| 282 | + | </h2> | |
| 283 | + | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 284 | + | Workflows that run in {workspace}'s repositories without a file in them, each as{" "} | |
| 285 | + | <code className="font-mono text-xs">managed/<name>.yml</code>. Each repository's Actions page lists them, managed by{" "} | |
| 286 | + | {workspace}. | |
| 287 | + | </p> | |
| 288 | + | </div> | |
| 289 | + | <ManagedWorkflowDialog workspace={workspace} /> | |
| 290 | + | </div> | |
| 291 | + | {managed.length === 0 ? ( | |
| 292 | + | <EmptyState title="No managed workflows"> | |
| 293 | + | Add a workflow file from one of the workspace's repositories to run it in all of them, or in those you choose. | |
| 294 | + | </EmptyState> | |
| 295 | + | ) : ( | |
| 296 | + | <Card asChild divided className="overflow-hidden"> | |
| 297 | + | <ul> | |
| 298 | + | {managed.map((workflow) => ( | |
| 299 | + | <ManagedWorkflowCard key={workflow.name} workspace={workspace} workflow={workflow} /> | |
| 300 | + | ))} | |
| 301 | + | </ul> | |
| 302 | + | </Card> | |
| 303 | + | )} | |
| 304 | + | </section> | |
| 305 | + | ); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | export default function WorkspaceActionsSettingsPage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 309 | + | const { settings, managed, owner } = loaderData; | |
| 310 | + | return ( | |
| 45 | 311 | <div className="space-y-6"> | |
| 46 | 312 | <header> | |
| 47 | 313 | <h1 className="text-xl font-semibold tracking-tight">Actions</h1> | |
| ⋯ | |||
| 106 | 372 | <p className="text-sm text-muted">Only the workspace's owners can change these.</p> | |
| 107 | 373 | )} | |
| 108 | 374 | </Form> | |
| 375 | + | {owner ? ( | |
| 376 | + | <ManagedWorkflows workspace={params.owner.toLowerCase()} managed={managed} /> | |
| 377 | + | ) : ( | |
| 378 | + | <section className="max-w-4xl border-t border-line pt-8"> | |
| 379 | + | <h2 className="font-medium">Workflows this workspace manages</h2> | |
| 380 | + | <p className="mt-1 text-sm text-muted"> | |
| 381 | + | Only the workspace's owners can see and change the workflows it runs in its repositories. Each repository's Actions | |
| 382 | + | page lists the ones that run there. | |
| 383 | + | </p> | |
| 384 | + | </section> | |
| 385 | + | )} | |
| 109 | 386 | </div> | |
| 110 | 387 | ); | |
| 111 | 388 | } | |
| 5 | 5 | //! [`FOLDER`] that no file can have. | |
| 6 | 6 | //! | |
| 7 | 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. | |
| 8 | + | //! `.g1t/workflows/`: that file runs instead ([`arrange`]). So moving a | |
| 9 | + | //! managed workflow into the repository is committing its source. A | |
| 10 | + | //! workspace's managed workflows ([`Target`], [`Mode`]) are the exception | |
| 11 | + | //! when they are required: then the repository's file of that name is set | |
| 12 | + | //! aside instead, and no repository can turn the workflow off. | |
| 10 | 13 | //! | |
| 11 | 14 | //! [`deploy_workflow`] writes the one deployments install: it calls g1t's | |
| 12 | 15 | //! own reusable deploy workflow with the project's settings as its inputs. | |
| ⋯ | |||
| 117 | 120 | workflow::parse(source).map(|parsed| parsed.display_name(path)).unwrap_or_else(|_| path.to_owned()) | |
| 118 | 121 | } | |
| 119 | 122 | ||
| 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() | |
| 123 | + | /// Whether `name` can end a managed workflow's path: lowercase letters, | |
| 124 | + | /// digits and hyphens, at most 64. | |
| 125 | + | pub fn valid_name(name: &str) -> bool { | |
| 126 | + | !name.is_empty() && name.len() <= 64 && name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// How a workspace's managed workflow holds in its repositories. | |
| 130 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 131 | + | pub enum Mode { | |
| 132 | + | /// It runs in every repository it targets: a workflow of the same | |
| 133 | + | /// `name:` in the repository is set aside, and no repository can turn | |
| 134 | + | /// it off, so its check can be required. | |
| 135 | + | Required, | |
| 136 | + | /// It runs where the repository has no workflow of the same `name:`, | |
| 137 | + | /// and the repository's admins can turn it off. | |
| 138 | + | Default, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | impl Mode { | |
| 142 | + | pub fn parse(text: &str) -> Option<Mode> { | |
| 143 | + | match text.trim().to_ascii_lowercase().as_str() { | |
| 144 | + | "required" => Some(Mode::Required), | |
| 145 | + | "default" => Some(Mode::Default), | |
| 146 | + | _ => None, | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | pub fn as_str(self) -> &'static str { | |
| 151 | + | match self { | |
| 152 | + | Mode::Required => "required", | |
| 153 | + | Mode::Default => "default", | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// The visibilities a target can name. | |
| 159 | + | pub const VISIBILITIES: [&str; 3] = ["all", "public", "private"]; | |
| 160 | + | ||
| 161 | + | /// Which of a workspace's repositories one of its managed workflows runs in. | |
| 162 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 163 | + | pub struct Target { | |
| 164 | + | /// Repository names and patterns (`*`, `[a-z]`; `!` excludes what an | |
| 165 | + | /// earlier one included), read in order, the last match deciding, | |
| 166 | + | /// without regard to case. Empty is every repository; only exclusions | |
| 167 | + | /// is every repository but those. | |
| 168 | + | pub repositories: Vec<String>, | |
| 169 | + | /// One of [`VISIBILITIES`]. | |
| 170 | + | pub visibility: String, | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | impl Target { | |
| 174 | + | /// Whether it takes in the repository `name`, private or not. A | |
| 175 | + | /// workflow read from a private repository (`source_private`) never | |
| 176 | + | /// runs in a public one, whose workflow page and logs anyone can read. | |
| 177 | + | pub fn includes(&self, name: &str, private: bool, source_private: bool) -> bool { | |
| 178 | + | if source_private && !private { | |
| 179 | + | return false; | |
| 180 | + | } | |
| 181 | + | match self.visibility.as_str() { | |
| 182 | + | "public" if private => return false, | |
| 183 | + | "private" if !private => return false, | |
| 184 | + | _ => {} | |
| 185 | + | } | |
| 186 | + | if self.repositories.is_empty() { | |
| 187 | + | return true; | |
| 188 | + | } | |
| 189 | + | let lowered: Vec<String> = self.repositories.iter().map(|pattern| pattern.trim().to_ascii_lowercase()).collect(); | |
| 190 | + | let mut patterns = crate::filter::Patterns::new(&lowered); | |
| 191 | + | if patterns.0.iter().all(|pattern| pattern.negated) { | |
| 192 | + | patterns.0.insert(0, crate::filter::Pattern::parse("*")); | |
| 193 | + | } | |
| 194 | + | patterns.includes(&name.to_ascii_lowercase()) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// A workflow a repository runs, as [`arrange`] decides. | |
| 199 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 200 | + | pub struct Arranged { | |
| 201 | + | pub path: String, | |
| 202 | + | pub source: String, | |
| 203 | + | /// A workspace's required workflow: nothing turns it off. | |
| 204 | + | pub required: bool, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// The workflows a repository runs: its own `files`, and the managed | |
| 208 | + | /// workflows offered to it, those `installed` in it by g1t's services (such | |
| 209 | + | /// as a project's deploy workflow) and its workspace's (`workspace`, each | |
| 210 | + | /// with its [`Mode`]), all as `(path, source)`. | |
| 211 | + | /// | |
| 212 | + | /// - The workspace's required workflows run, and a file of the same | |
| 213 | + | /// `name:` is set aside, so nothing else reports their checks. | |
| 214 | + | /// - Then the repository's own files. | |
| 215 | + | /// - Then each other managed workflow that no workflow before it of the | |
| 216 | + | /// same `name:` or path takes the place of: those installed in the | |
| 217 | + | /// repository first, then the workspace's defaults. | |
| 218 | + | pub fn arrange(files: Vec<(String, String)>, installed: Vec<(String, String)>, workspace: Vec<(String, String, Mode)>) -> Vec<Arranged> { | |
| 219 | + | let mut names: Vec<String> = Vec::new(); | |
| 220 | + | let mut paths: Vec<String> = Vec::new(); | |
| 221 | + | let mut out: Vec<Arranged> = Vec::new(); | |
| 222 | + | let (required, defaults): (Vec<_>, Vec<_>) = workspace.into_iter().partition(|(_, _, mode)| *mode == Mode::Required); | |
| 223 | + | let offer = |path: String, source: String, required: bool, out: &mut Vec<Arranged>, names: &mut Vec<String>, paths: &mut Vec<String>| { | |
| 224 | + | let name = listed_name(&path, &source); | |
| 225 | + | if names.contains(&name) || paths.contains(&path) { | |
| 226 | + | return; | |
| 227 | + | } | |
| 228 | + | names.push(name); | |
| 229 | + | paths.push(path.clone()); | |
| 230 | + | out.push(Arranged { path, source, required }); | |
| 231 | + | }; | |
| 232 | + | for (path, source, _) in required { | |
| 233 | + | offer(path, source, true, &mut out, &mut names, &mut paths); | |
| 234 | + | } | |
| 235 | + | // A repository's files are set aside only for a required workflow's | |
| 236 | + | // name, never for each other's. | |
| 237 | + | let required_names = names.clone(); | |
| 238 | + | for (path, source) in files { | |
| 239 | + | let name = listed_name(&path, &source); | |
| 240 | + | if required_names.contains(&name) { | |
| 241 | + | continue; | |
| 242 | + | } | |
| 243 | + | names.push(name); | |
| 244 | + | paths.push(path.clone()); | |
| 245 | + | out.push(Arranged { path, source, required: false }); | |
| 246 | + | } | |
| 247 | + | for (path, source) in installed.into_iter().chain(defaults.into_iter().map(|(path, source, _)| (path, source))) { | |
| 248 | + | offer(path, source, false, &mut out, &mut names, &mut paths); | |
| 249 | + | } | |
| 250 | + | out | |
| 126 | 251 | } | |
| 127 | 252 | ||
| 128 | 253 | #[cfg(test)] | |
| ⋯ | |||
| 192 | 317 | assert_eq!(with["root-dir"], "."); | |
| 193 | 318 | } | |
| 194 | 319 | ||
| 320 | + | fn own(path: &str, name: &str) -> (String, String) { | |
| 321 | + | (path.to_owned(), format!("name: {name}\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{{ run: 'true' }}]\n")) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | fn paths(arranged: &[Arranged]) -> Vec<&str> { | |
| 325 | + | arranged.iter().map(|kept| kept.path.as_str()).collect() | |
| 326 | + | } | |
| 327 | + | ||
| 195 | 328 | #[test] | |
| 196 | 329 | fn a_repository_workflow_of_the_same_name_takes_its_place() { | |
| 197 | 330 | let deploy = (path("deploy"), deploy_workflow(&settings()).unwrap()); | |
| 198 | − | let other = (path("scan"), "name: Scan\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n".to_owned()); | |
| 199 | − | let own = "name: g1t\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]\n"; | |
| 200 | − | let kept = prefer_repository([(".g1t/workflows/g1t.yml", own)], vec![deploy.clone(), other.clone()]); | |
| 201 | − | assert_eq!(kept, vec![other.clone()]); | |
| 202 | − | let kept = prefer_repository([(".g1t/workflows/ci.yml", "name: CI\non: push\njobs: {}\n")], vec![deploy.clone(), other]); | |
| 203 | − | assert_eq!(kept.len(), 2); | |
| 331 | + | let other = own(&path("scan"), "Scan"); | |
| 332 | + | let kept = arrange(vec![own(".g1t/workflows/g1t.yml", "g1t")], vec![deploy.clone(), other.clone()], vec![]); | |
| 333 | + | assert_eq!(paths(&kept), [".g1t/workflows/g1t.yml", "managed/scan.yml"]); | |
| 334 | + | let kept = arrange(vec![own(".g1t/workflows/ci.yml", "CI")], vec![deploy.clone(), other], vec![]); | |
| 335 | + | assert_eq!(kept.len(), 3); | |
| 336 | + | assert!(kept.iter().all(|kept| !kept.required)); | |
| 204 | 337 | assert!(is_managed(&deploy.0) && !is_managed(".g1t/workflows/g1t.yml") && !is_managed("managedx/a.yml")); | |
| 205 | 338 | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn a_required_workflow_sets_aside_a_file_of_its_name() { | |
| 342 | + | let files = vec![own(".g1t/workflows/scan.yml", "Scan"), own(".g1t/workflows/ci.yml", "CI")]; | |
| 343 | + | let kept = arrange(files, vec![], vec![(path("scan"), own("", "Scan").1, Mode::Required)]); | |
| 344 | + | assert_eq!(paths(&kept), ["managed/scan.yml", ".g1t/workflows/ci.yml"]); | |
| 345 | + | assert!(kept[0].required && !kept[1].required); | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | #[test] | |
| 349 | + | fn a_default_workflow_gives_way_to_a_file_of_its_name() { | |
| 350 | + | let files = vec![own(".g1t/workflows/lint.yml", "Lint")]; | |
| 351 | + | let workspace = vec![(path("lint"), own("", "Lint").1, Mode::Default), (path("audit"), own("", "Audit").1, Mode::Default)]; | |
| 352 | + | let kept = arrange(files, vec![], workspace); | |
| 353 | + | assert_eq!(paths(&kept), [".g1t/workflows/lint.yml", "managed/audit.yml"]); | |
| 354 | + | assert!(kept.iter().all(|kept| !kept.required)); | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | #[test] | |
| 358 | + | fn a_repository_keeps_two_files_of_one_name() { | |
| 359 | + | let files = vec![own(".g1t/workflows/a.yml", "Same"), own(".g1t/workflows/b.yml", "Same")]; | |
| 360 | + | assert_eq!(arrange(files, vec![], vec![]).len(), 2); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[test] | |
| 364 | + | fn what_is_installed_in_the_repository_comes_between_required_and_default() { | |
| 365 | + | // The same path: required beats installed, installed beats default. | |
| 366 | + | let installed = vec![own(&path("deploy-web"), "Deploy")]; | |
| 367 | + | let kept = arrange(vec![], installed.clone(), vec![(path("deploy-web"), own("", "Policy").1, Mode::Required)]); | |
| 368 | + | assert_eq!(kept.len(), 1); | |
| 369 | + | assert!(kept[0].source.contains("Policy") && kept[0].required); | |
| 370 | + | let kept = arrange(vec![], installed, vec![(path("deploy-web"), own("", "Policy").1, Mode::Default)]); | |
| 371 | + | assert_eq!(kept.len(), 1); | |
| 372 | + | assert!(kept[0].source.contains("Deploy") && !kept[0].required); | |
| 373 | + | // The same name at another path: the first offered stays. | |
| 374 | + | let kept = arrange(vec![], vec![own(&path("a"), "Same")], vec![(path("b"), own("", "Same").1, Mode::Default)]); | |
| 375 | + | assert_eq!(paths(&kept), ["managed/a.yml"]); | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | #[test] | |
| 379 | + | fn targets_take_names_patterns_and_visibility() { | |
| 380 | + | let all = Target { repositories: vec![], visibility: "all".into() }; | |
| 381 | + | assert!(all.includes("web", true, false) && all.includes("web", false, false)); | |
| 382 | + | let some = Target { repositories: vec!["web".into(), "api-*".into()], visibility: "all".into() }; | |
| 383 | + | assert!(some.includes("web", false, false) && some.includes("API-gateway", false, false)); | |
| 384 | + | assert!(!some.includes("docs", false, false) && !some.includes("webx", false, false)); | |
| 385 | + | let but = Target { repositories: vec!["!sandbox-*".into()], visibility: "all".into() }; | |
| 386 | + | assert!(but.includes("web", false, false) && !but.includes("sandbox-1", false, false)); | |
| 387 | + | let then_back = Target { repositories: vec!["*".into(), "!sandbox-*".into(), "sandbox-keep".into()], visibility: "all".into() }; | |
| 388 | + | assert!(then_back.includes("sandbox-keep", false, false) && !then_back.includes("sandbox-2", false, false)); | |
| 389 | + | let private = Target { repositories: vec![], visibility: "private".into() }; | |
| 390 | + | assert!(private.includes("web", true, false) && !private.includes("web", false, false)); | |
| 391 | + | let public = Target { repositories: vec![], visibility: "public".into() }; | |
| 392 | + | assert!(!public.includes("web", true, false) && public.includes("web", false, false)); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | #[test] | |
| 396 | + | fn a_private_source_never_runs_in_a_public_repository() { | |
| 397 | + | let all = Target { repositories: vec!["*".into()], visibility: "all".into() }; | |
| 398 | + | assert!(all.includes("web", true, true)); | |
| 399 | + | assert!(!all.includes("site", false, true)); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn modes_and_names_read_as_written() { | |
| 404 | + | assert_eq!(Mode::parse("Required"), Some(Mode::Required)); | |
| 405 | + | assert_eq!(Mode::parse("default").map(Mode::as_str), Some("default")); | |
| 406 | + | assert_eq!(Mode::parse("optional"), None); | |
| 407 | + | assert!(valid_name("deploy") && valid_name("code-scanning-2")); | |
| 408 | + | for bad in ["", "Deploy", "a/b", "a.yml", "../x", &"a".repeat(65)] { | |
| 409 | + | assert!(!valid_name(bad), "{bad}"); | |
| 410 | + | } | |
| 411 | + | } | |
| 206 | 412 | } | |
| 47 | 47 | /// `on.workflow_dispatch.inputs` as written, when it can be run by hand. | |
| 48 | 48 | pub dispatch: Option<Value>, | |
| 49 | 49 | pub last_run: Option<WorkflowRun>, | |
| 50 | + | /// For a managed workflow (`managed/<name>.yml`): what installed it. | |
| 51 | + | #[serde(default)] | |
| 52 | + | pub managed_by: Option<ManagedBy>, | |
| 50 | 53 | } | |
| 51 | 54 | ||
| 55 | + | /// What installed a managed workflow in a repository. | |
| 56 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 57 | + | #[serde(rename_all = "camelCase")] | |
| 58 | + | pub struct ManagedBy { | |
| 59 | + | /// `g1t` for one g1t's services installed (such as a project's deploy | |
| 60 | + | /// workflow), or the workspace's name for one its owners added. | |
| 61 | + | pub origin: String, | |
| 62 | + | /// The workspace's: `required` or `default`. Empty for g1t's. | |
| 63 | + | #[serde(default)] | |
| 64 | + | pub mode: String, | |
| 65 | + | /// The workspace's: the file it was copied from, as | |
| 66 | + | /// `owner/repo/path@ref`. | |
| 67 | + | #[serde(default)] | |
| 68 | + | pub source: Option<String>, | |
| 69 | + | } | |
| 70 | + | ||
| 52 | 71 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 53 | 72 | #[serde(rename_all = "camelCase")] | |
| 54 | 73 | pub struct WorkflowRun { | |
| ⋯ | |||
| 803 | 822 | pub production: bool, | |
| 804 | 823 | } | |
| 805 | 824 | ||
| 825 | + | /// A workflow a workspace's owners added to its repositories | |
| 826 | + | /// (`g1t_actions::managed::Target`): it runs in each repository it targets | |
| 827 | + | /// as that repository's own, with its token and secrets, under the path | |
| 828 | + | /// `managed/<name>.yml`, from a copy of its source taken when it was saved. | |
| 829 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 830 | + | #[serde(rename_all = "camelCase")] | |
| 831 | + | pub struct WorkspaceManagedWorkflow { | |
| 832 | + | /// Lowercase letters, digits and hyphens: its path is | |
| 833 | + | /// `managed/<name>.yml`. | |
| 834 | + | pub name: String, | |
| 835 | + | pub path: String, | |
| 836 | + | /// Its `name:`, as repositories list it; a repository's workflow of | |
| 837 | + | /// this name takes a default one's place. | |
| 838 | + | pub workflow_name: String, | |
| 839 | + | /// Where it was copied from: `owner/repo/path@ref`, a repository of the | |
| 840 | + | /// same workspace. | |
| 841 | + | pub source: String, | |
| 842 | + | /// The commit it was copied at. | |
| 843 | + | pub source_sha: Option<String>, | |
| 844 | + | /// Whether the source repository is private, so the workflow runs in | |
| 845 | + | /// private repositories only. | |
| 846 | + | pub source_private: bool, | |
| 847 | + | /// `required` or `default`. | |
| 848 | + | pub mode: String, | |
| 849 | + | /// Names and patterns of the repositories it runs in; empty is every one. | |
| 850 | + | pub repositories: Vec<String>, | |
| 851 | + | /// `all`, `public` or `private`. | |
| 852 | + | pub visibility: String, | |
| 853 | + | /// The copy that runs. | |
| 854 | + | pub content: String, | |
| 855 | + | pub created_at: String, | |
| 856 | + | pub updated_at: String, | |
| 857 | + | pub updated_by: String, | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | /// `workspace_managed_workflows`: owners only. Returns | |
| 861 | + | /// `Outcome<Vec<WorkspaceManagedWorkflow>>`. | |
| 862 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 863 | + | pub struct WorkspaceManagedWorkflowsArgs { | |
| 864 | + | pub viewer: Viewer, | |
| 865 | + | pub workspace: String, | |
| 866 | + | } | |
| 867 | + | ||
| 868 | + | /// `set_workspace_managed_workflow`: owners only. Adds one (`create`, which | |
| 869 | + | /// needs `source` and `mode`) or changes one; fields left out stay as they | |
| 870 | + | /// are. The source is copied again when it is given or `refresh` is set. | |
| 871 | + | /// Refused when the source is not a workflow file in a repository of the | |
| 872 | + | /// workspace, or is private while the workflow could run in a public | |
| 873 | + | /// repository. Returns `Outcome<WorkspaceManagedWorkflow>`. | |
| 874 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 875 | + | #[serde(rename_all = "camelCase")] | |
| 876 | + | pub struct SetWorkspaceManagedWorkflowArgs { | |
| 877 | + | pub actor: User, | |
| 878 | + | pub workspace: String, | |
| 879 | + | pub name: String, | |
| 880 | + | #[serde(default)] | |
| 881 | + | pub create: bool, | |
| 882 | + | /// `owner/repo/.g1t/workflows/file.yml@ref`. | |
| 883 | + | #[serde(default)] | |
| 884 | + | pub source: Option<String>, | |
| 885 | + | #[serde(default)] | |
| 886 | + | pub mode: Option<String>, | |
| 887 | + | #[serde(default)] | |
| 888 | + | pub repositories: Option<Vec<String>>, | |
| 889 | + | #[serde(default)] | |
| 890 | + | pub visibility: Option<String>, | |
| 891 | + | #[serde(default)] | |
| 892 | + | pub refresh: bool, | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | /// `remove_workspace_managed_workflow`: owners only. Its runs stay. | |
| 896 | + | /// Returns `Outcome<bool>`, whether there was one. | |
| 897 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 898 | + | pub struct RemoveWorkspaceManagedWorkflowArgs { | |
| 899 | + | pub actor: User, | |
| 900 | + | pub workspace: String, | |
| 901 | + | pub name: String, | |
| 902 | + | } | |
| 903 | + | ||
| 806 | 904 | /// `remove_managed_workflow`: takes a managed workflow out of a repository. | |
| 807 | 905 | /// Its runs stay. Returns `bool`, whether there was one. | |
| 808 | 906 | #[derive(Debug, Serialize, Deserialize)] | |
| 1197 | 1197 | // A workspace's policy for its repositories' tokens. | |
| 1198 | 1198 | ("get_workspace_workflow_permissions", Scope::WorkspaceRead), | |
| 1199 | 1199 | ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin), | |
| 1200 | + | // The workflows a workspace runs in its repositories: its owners'. | |
| 1201 | + | ("list_workspace_managed_workflows", Scope::WorkspaceRead), | |
| 1202 | + | ("create_workspace_managed_workflow", Scope::WorkspaceAdmin), | |
| 1203 | + | ("update_workspace_managed_workflow", Scope::WorkspaceAdmin), | |
| 1204 | + | ("delete_workspace_managed_workflow", Scope::WorkspaceAdmin), | |
| 1200 | 1205 | // A workspace's rules for personal access tokens, and the members' | |
| 1201 | 1206 | // tokens that reach it: who has access. | |
| 1202 | 1207 | ("get_token_policy", Scope::WorkspaceRead), |
| 61 | 61 | notes: WorkflowNote[]; | |
| 62 | 62 | dispatch: Record<string, DispatchInput> | null; | |
| 63 | 63 | lastRun: WorkflowRun | null; | |
| 64 | + | /** For a managed workflow (`managed/<name>.yml`): what installed it. */ | |
| 65 | + | managedBy?: ManagedBy | null; | |
| 66 | + | }; | |
| 67 | + | ||
| 68 | + | /** What installed a managed workflow in a repository. */ | |
| 69 | + | export type ManagedBy = { | |
| 70 | + | /** `g1t` for one g1t's services installed, or the workspace's name. */ | |
| 71 | + | origin: string; | |
| 72 | + | /** A workspace's: `required` or `default`; empty for g1t's. */ | |
| 73 | + | mode: "required" | "default" | ""; | |
| 74 | + | /** A workspace's: the file it was copied from, `owner/repo/path@ref`. */ | |
| 75 | + | source: string | null; | |
| 76 | + | }; | |
| 77 | + | ||
| 78 | + | /** | |
| 79 | + | * A workflow a workspace's owners added to its repositories: it runs in | |
| 80 | + | * each repository it targets as that repository's own, under | |
| 81 | + | * `managed/<name>.yml`, from a copy of its source taken when it was saved. | |
| 82 | + | */ | |
| 83 | + | export type WorkspaceManagedWorkflow = { | |
| 84 | + | name: string; | |
| 85 | + | path: string; | |
| 86 | + | /** Its `name:`, as repositories list it. */ | |
| 87 | + | workflowName: string; | |
| 88 | + | /** `owner/repo/.g1t/workflows/file.yml@ref`. */ | |
| 89 | + | source: string; | |
| 90 | + | sourceSha: string | null; | |
| 91 | + | /** A private source runs in private repositories only. */ | |
| 92 | + | sourcePrivate: boolean; | |
| 93 | + | mode: "required" | "default"; | |
| 94 | + | /** Names and patterns; empty is every repository. */ | |
| 95 | + | repositories: string[]; | |
| 96 | + | visibility: "all" | "public" | "private"; | |
| 97 | + | content: string; | |
| 98 | + | createdAt: string; | |
| 99 | + | updatedAt: string; | |
| 100 | + | updatedBy: string; | |
| 64 | 101 | }; | |
| 65 | 102 | ||
| 103 | + | export type WorkspaceManagedWorkflowChange = { | |
| 104 | + | source?: string; | |
| 105 | + | mode?: "required" | "default"; | |
| 106 | + | repositories?: string[]; | |
| 107 | + | visibility?: "all" | "public" | "private"; | |
| 108 | + | /** Copy the source again. */ | |
| 109 | + | refresh?: boolean; | |
| 110 | + | }; | |
| 111 | + | ||
| 66 | 112 | export type StepState = { | |
| 67 | 113 | number: number; | |
| 68 | 114 | name: string; | |
| ⋯ | |||
| 386 | 432 | workspace: string, | |
| 387 | 433 | change: Partial<WorkspaceActionsSettings>, | |
| 388 | 434 | ): Promise<Result<WorkspaceActionsSettings>>; | |
| 435 | + | /** Owners. */ | |
| 436 | + | workspaceManagedWorkflows(workspace: string, viewer: Viewer): Promise<Result<WorkspaceManagedWorkflow[]>>; | |
| 437 | + | /** Owners. `create` adds one, which needs `source` and `mode`; otherwise left out is unchanged. */ | |
| 438 | + | setWorkspaceManagedWorkflow( | |
| 439 | + | actor: User, | |
| 440 | + | workspace: string, | |
| 441 | + | name: string, | |
| 442 | + | change: WorkspaceManagedWorkflowChange, | |
| 443 | + | create?: boolean, | |
| 444 | + | ): Promise<Result<WorkspaceManagedWorkflow>>; | |
| 445 | + | /** Owners. */ | |
| 446 | + | removeWorkspaceManagedWorkflow(actor: User, workspace: string, name: string): Promise<Result<boolean>>; | |
| 389 | 447 | /** Every environment the repository's rules, secrets, workflows or jobs name. */ | |
| 390 | 448 | environments(repo: RepoPath, viewer: Viewer): Promise<Result<Environment[]>>; | |
| 391 | 449 | /** Admin role. */ | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…