A workspace can manage workflows in its repositories, as GitHub's required workflows do, through g1t's managed mechanism. An owner adds a workflow file from one of the workspace's own repositories (`owner/repo/.g1t/workflows/file.yml@ref`) with a name, a mode and a target, kept in the actions service's new `workspace_managed_workflows` table (migration 0017): the file is copied at the commit its ref names when it is saved, so only an owner changes what runs with every repository's secrets, and copying it again takes up a change. The target is repository names and patterns (`*`, `!` to leave one out, the last match deciding; empty is every repository) and a visibility, and a source in a private repository runs in private repositories only, as reach.rs keeps a public repository from using a private one's workflows. Wherever a repository's workflows are read, a fork's pull request and the merge queue included, `g1t_actions::managed::arrange` puts its files and the managed workflows together: a required workflow runs as `managed/<name>.yml` and sets aside a file of its `name:`, so nothing else reports its check and a ruleset can require it, and can't be turned off in the repository; a default one gives way to a file of its name, as the workflows g1t's services install do, and a repository admin can turn it off. It runs as the repository's own workflow, with its token and secrets. A change syncs the first 20 repositories it reaches at once and leaves the rest to be synced when their workflows are listed or by the minute's sweep, a few a minute, their managed schedules waiting until then; it is recorded in the audit log. A workflow's view says what manages it (`managedBy`), and the Actions list now includes managed workflows, which it had left out. The API has `GET` and `POST /workspaces/{workspace}/actions/managed-workflows` and `PATCH` and `DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`, owners only, and the `workflow` MCP tool `list_managed_workflows`, `add_managed_workflow`, `update_managed_workflow` and `remove_managed_workflow`; the reference and the OpenAPI copy say so.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
24 files+713−228 files not counted0/24 viewed
| 496 | 496 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 497 | 497 | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 498 | 498 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 499 | + | Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), | |
| 500 | + | Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), | |
| 501 | + | Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), | |
| 502 | + | Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), | |
| 499 | 503 | ], | |
| 500 | 504 | ), | |
| 501 | 505 | ( |
| 711 | 711 | } | |
| 712 | 712 | ||
| 713 | 713 | impl Op { | |
| 714 | − | pub const ALL: [Op; 380] = [ | |
| 714 | + | pub const ALL: [Op; 384] = [ | |
| 715 | 715 | Op::Whoami, | |
| 716 | 716 | Op::GetWorkspace, | |
| 717 | 717 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1052 | 1052 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 1053 | 1053 | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 1054 | 1054 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 1055 | + | Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), | |
| 1056 | + | Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), | |
| 1057 | + | Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), | |
| 1058 | + | Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), | |
| 1055 | 1059 | Op::Tokens(TokenOp::GetTokenPolicy), | |
| 1056 | 1060 | Op::Tokens(TokenOp::SetTokenPolicy), | |
| 1057 | 1061 | 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" |
| 421 | 421 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 422 | 422 | &[], | |
| 423 | 423 | ), | |
| 424 | + | route( | |
| 425 | + | "GET", | |
| 426 | + | "/workspaces/:workspace/actions/managed-workflows", | |
| 427 | + | Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), | |
| 428 | + | &[], | |
| 429 | + | ), | |
| 430 | + | route( | |
| 431 | + | "POST", | |
| 432 | + | "/workspaces/:workspace/actions/managed-workflows", | |
| 433 | + | Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), | |
| 434 | + | &[], | |
| 435 | + | ), | |
| 436 | + | route( | |
| 437 | + | "PATCH", | |
| 438 | + | "/workspaces/:workspace/actions/managed-workflows/:workflow", | |
| 439 | + | Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), | |
| 440 | + | &[], | |
| 441 | + | ), | |
| 442 | + | route( | |
| 443 | + | "DELETE", | |
| 444 | + | "/workspaces/:workspace/actions/managed-workflows/:workflow", | |
| 445 | + | Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), | |
| 446 | + | &[], | |
| 447 | + | ), | |
| 424 | 448 | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 425 | 449 | route( | |
| 426 | 450 | "POST", |
| 227 | 227 | Tool { | |
| 228 | 228 | name: "workflow", | |
| 229 | 229 | title: "Workflows", | |
| 230 | − | 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.", | |
| 230 | + | 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.", | |
| 231 | 231 | default_action: None, | |
| 232 | 232 | actions: &[ | |
| 233 | 233 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| ⋯ | |||
| 279 | 279 | a("repository_dispatch", Op::Protection(ProtectionOp::CreateRepositoryDispatch), "Start repository_dispatch workflows with an event"), | |
| 280 | 280 | a("get_workspace_permissions", Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), "A workspace's default and maximum token permissions"), | |
| 281 | 281 | a("set_workspace_permissions", Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), "Set them, and whether jobs may open pull requests"), | |
| 282 | + | a("list_managed_workflows", Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), "The workflows a workspace runs in its repositories"), | |
| 283 | + | a("add_managed_workflow", Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), "Run a workflow in the workspace's repositories, required or default"), | |
| 284 | + | a("update_managed_workflow", Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), "Change one's repositories or mode, or copy its source again"), | |
| 285 | + | a("remove_managed_workflow", Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), "Take one out of the workspace's repositories"), | |
| 282 | 286 | a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"), | |
| 283 | 287 | a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"), | |
| 284 | 288 | a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"), | |
| 474 | 474 | | [`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` | | |
| 475 | 475 | | [`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` | | |
| 476 | 476 | | [`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` | | |
| 477 | + | | [`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` | | |
| 478 | + | | [`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` | | |
| 479 | + | | [`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` | | |
| 480 | + | | [`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` | | |
| 477 | 481 | | [`get_approval_policy`](/reference/api/run-protection/get-fork-pr-approval/) | Which pull requests' runs wait for approval. | `repo` | `repo:read` | | |
| 478 | 482 | | [`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` | | |
| 479 | 483 | | [`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.
| 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)] | |
| 1194 | 1194 | // A workspace's policy for its repositories' tokens. | |
| 1195 | 1195 | ("get_workspace_workflow_permissions", Scope::WorkspaceRead), | |
| 1196 | 1196 | ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin), | |
| 1197 | + | // The workflows a workspace runs in its repositories: its owners'. | |
| 1198 | + | ("list_workspace_managed_workflows", Scope::WorkspaceRead), | |
| 1199 | + | ("create_workspace_managed_workflow", Scope::WorkspaceAdmin), | |
| 1200 | + | ("update_workspace_managed_workflow", Scope::WorkspaceAdmin), | |
| 1201 | + | ("delete_workspace_managed_workflow", Scope::WorkspaceAdmin), | |
| 1197 | 1202 | // A workspace's rules for personal access tokens, and the members' | |
| 1198 | 1203 | // tokens that reach it: who has access. | |
| 1199 | 1204 | ("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. */ | |
| 834 | 834 | workspaceActionsSettings: (workspace, viewer) => call("workspace_actions_settings", { workspace, viewer }), | |
| 835 | 835 | setWorkspaceActionsSettings: (actor, workspace, change) => | |
| 836 | 836 | call("set_workspace_actions_settings", { actor, workspace, ...change }), | |
| 837 | + | workspaceManagedWorkflows: (workspace, viewer) => call("workspace_managed_workflows", { workspace, viewer }), | |
| 838 | + | setWorkspaceManagedWorkflow: (actor, workspace, name, change, create = false) => | |
| 839 | + | call("set_workspace_managed_workflow", { actor, workspace, name, create, ...change }), | |
| 840 | + | removeWorkspaceManagedWorkflow: (actor, workspace, name) => | |
| 841 | + | call("remove_workspace_managed_workflow", { actor, workspace, name }), | |
| 837 | 842 | environments: (repo, viewer) => call("environments", { repo, viewer }), | |
| 838 | 843 | setEnvironment: (actor, repo, name, change) => call("set_environment", { actor, repo, name, ...change }), | |
| 839 | 844 | deleteEnvironment: (actor, repo, name) => call("delete_environment", { actor, repo, name }), |
| 565 | 565 | // A workspace's policy for its repositories' tokens. | |
| 566 | 566 | ["get_workspace_workflow_permissions", "workspace:read"], | |
| 567 | 567 | ["set_workspace_workflow_permissions", "workspace:admin"], | |
| 568 | + | ["list_workspace_managed_workflows", "workspace:read"], | |
| 569 | + | ["create_workspace_managed_workflow", "workspace:admin"], | |
| 570 | + | ["update_workspace_managed_workflow", "workspace:admin"], | |
| 571 | + | ["delete_workspace_managed_workflow", "workspace:admin"], | |
| 568 | 572 | // A workspace's rules for personal access tokens, and its members' tokens. | |
| 569 | 573 | ["get_token_policy", "workspace:read"], | |
| 570 | 574 | ["set_token_policy", "workspace:admin"], |
| 1 | + | -- A workspace's managed workflows (src/managed.rs): workflows its owners | |
| 2 | + | -- add to its repositories, each run in every repository it targets as that | |
| 3 | + | -- repository's own, under the path `managed/<name>.yml`. `source` is a copy | |
| 4 | + | -- of the workflow file it was added from, taken when it was saved, so only | |
| 5 | + | -- an owner changes what runs. `mode` is `required` (a repository's workflow | |
| 6 | + | -- of the same `name:` is set aside, and no repository turns it off) or | |
| 7 | + | -- `default` (a repository's workflow of the same name runs in its place, | |
| 8 | + | -- and its admins can turn it off). | |
| 9 | + | CREATE TABLE IF NOT EXISTS workspace_managed_workflows ( | |
| 10 | + | namespace TEXT NOT NULL, | |
| 11 | + | name TEXT NOT NULL, | |
| 12 | + | -- `owner/repo/path@ref`, as given. | |
| 13 | + | source_ref TEXT NOT NULL, | |
| 14 | + | source_repo_id TEXT NOT NULL, | |
| 15 | + | source_sha TEXT, | |
| 16 | + | source_private INTEGER NOT NULL DEFAULT 0, | |
| 17 | + | source TEXT NOT NULL, | |
| 18 | + | -- Its `name:`, as repositories list it. | |
| 19 | + | workflow_name TEXT NOT NULL, | |
| 20 | + | mode TEXT NOT NULL, | |
| 21 | + | -- JSON: names and patterns; empty is every repository. | |
| 22 | + | repositories TEXT NOT NULL DEFAULT '[]', | |
| 23 | + | visibility TEXT NOT NULL DEFAULT 'all', | |
| 24 | + | created_at TEXT NOT NULL, | |
| 25 | + | updated_at TEXT NOT NULL, | |
| 26 | + | updated_by TEXT NOT NULL, | |
| 27 | + | PRIMARY KEY (namespace, name) | |
| 28 | + | ); |
| 256 | 256 | "set_managed_workflow" => reply(&service.set_managed_workflow(args(body)?).await?), | |
| 257 | 257 | "remove_managed_workflow" => reply(&service.remove_managed_workflow(args(body)?).await?), | |
| 258 | 258 | "set_managed_deploy" => reply(&service.set_managed_deploy(args(body)?).await?), | |
| 259 | + | // A workspace's managed workflows: its owners'. | |
| 260 | + | "workspace_managed_workflows" => reply(&service.workspace_managed_workflows(args(body)?).await?), | |
| 261 | + | "set_workspace_managed_workflow" => reply(&service.set_workspace_managed_workflow(args(body)?).await?), | |
| 262 | + | "remove_workspace_managed_workflow" => reply(&service.remove_workspace_managed_workflow(args(body)?).await?), | |
| 259 | 263 | "job_spec" => reply(&service.job_spec(args(body)?).await?), | |
| 260 | 264 | "job_auth" => reply(&service.job_auth(args(body)?).await?), | |
| 261 | 265 | "job_action" => reply(&service.job_action(args(body)?).await?), |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…