Skip to content

Commit

Merge branch 'worktree-agent-a209db8188a251e77' into claude/cool-newton-7nm8r9

# Conflicts: # apps/api/src/operations.rs

Claudeagentcommitted Parents4f0f25fab407e8Browse files
28 files+1069−3512 files not counted0/28 viewed
+4−0
499499 Op::Protection(ProtectionOp::CreateRepositoryDispatch),
500500 Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
501501 Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
502+ Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows),
503+ Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow),
504+ Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow),
505+ Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow),
502506 ],
503507 ),
504508 (
+5−1
714714 }
715715
716716 impl Op {
717− pub const ALL: [Op; 382] = [
717+ pub const ALL: [Op; 386] = [
718718 Op::Whoami,
719719 Op::GetWorkspace,
720720 Op::CreateWorkspace,
10571057 Op::Protection(ProtectionOp::CreateRepositoryDispatch),
10581058 Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
10591059 Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
1060+ Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows),
1061+ Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow),
1062+ Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow),
1063+ Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow),
10601064 Op::Tokens(TokenOp::GetTokenPolicy),
10611065 Op::Tokens(TokenOp::SetTokenPolicy),
10621066 Op::Tokens(TokenOp::ListMemberTokens),
+151−6
2929 CreateRepositoryDispatch,
3030 GetWorkspaceWorkflowPermissions,
3131 SetWorkspaceWorkflowPermissions,
32+ ListWorkspaceManagedWorkflows,
33+ CreateWorkspaceManagedWorkflow,
34+ UpdateWorkspaceManagedWorkflow,
35+ DeleteWorkspaceManagedWorkflow,
3236 }
3337
3438 impl ProtectionOp {
3539 /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test
3640 /// checks against this.
3741 #[cfg(test)]
38− pub const ALL: [ProtectionOp; 14] = [
42+ pub const ALL: [ProtectionOp; 18] = [
3943 ProtectionOp::UpdateEnvironment,
4044 ProtectionOp::DeleteEnvironment,
4145 ProtectionOp::GetPendingDeployments,
5054 ProtectionOp::CreateRepositoryDispatch,
5155 ProtectionOp::GetWorkspaceWorkflowPermissions,
5256 ProtectionOp::SetWorkspaceWorkflowPermissions,
57+ ProtectionOp::ListWorkspaceManagedWorkflows,
58+ ProtectionOp::CreateWorkspaceManagedWorkflow,
59+ ProtectionOp::UpdateWorkspaceManagedWorkflow,
60+ ProtectionOp::DeleteWorkspaceManagedWorkflow,
5361 ];
5462
5563 pub fn name(self) -> &'static str {
6876 ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch",
6977 ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions",
7078 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",
7183 }
7284 }
7385
7486 /// Whether it is about one repository, named by `repo`; the rest are a
7587 /// workspace's.
7688 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+ )
78101 }
79102
80103 pub fn title(self) -> &'static str {
93116 ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event",
94117 ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions",
95118 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",
96123 }
97124 }
98125
111138 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.",
112139 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.",
113140 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.",
114145 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.",
115146 }
116147 }
124155 | ProtectionOp::GetForkPrApproval
125156 | ProtectionOp::GetActionsAccess
126157 | ProtectionOp::GetWorkspaceWorkflowPermissions
158+ | ProtectionOp::ListWorkspaceManagedWorkflows
127159 )
128160 }
129161
201233 }),
202234 &["repo"],
203235 ),
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"]),
205261 ProtectionOp::SetWorkspaceWorkflowPermissions => (
206262 json!({
207263 "workspace": workspace,
231287 }
232288 }
233289
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+
234373 fn text(input: &Value, key: &str) -> Option<String> {
235374 match &input[key] {
236375 Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
455594 let Some(workspace) = text(input, "workspace") else {
456595 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
457596 };
597+ if op.managed() {
598+ return run_managed(op, actions, viewer, actor(), workspace, input).await;
599+ }
458600 let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions {
459601 g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await?
460602 } else {
587729 map(started, |runs| json!({ "runs": runs }))
588730 }
589731 // 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."),
593738 })
594739 }
595740
+98−0
1222812228 },
1222912229 "notes": "A maximum of read holds every repository's default to read-only, and makes the workspace's default read too."
1223012230 },
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+ },
1223112329 "get_token_policy": {
1223212330 "params": {
1223312331 "workspace": "flagon-io"
+24−0
426426 Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
427427 &[],
428428 ),
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+ ),
429453 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
430454 route(
431455 "POST",
+5−1
230230 Tool {
231231 name: "workflow",
232232 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.",
234234 default_action: None,
235235 actions: &[
236236 a("list", Op::ListWorkflows, "Workflows on the default branch"),
282282 a("repository_dispatch", Op::Protection(ProtectionOp::CreateRepositoryDispatch), "Start repository_dispatch workflows with an event"),
283283 a("get_workspace_permissions", Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), "A workspace's default and maximum token permissions"),
284284 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"),
285289 a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"),
286290 a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"),
287291 a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"),
+68−0
180180 A workflow of the same `name:` in `.g1t/workflows/` runs in its place, so
181181 to change one, commit your own version of it.
182182
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+
183251 ## Schedules
184252
185253 A workflow with `on: schedule` runs on the default branch's latest commit,
+14−1
486486 sets any of them (`workspace` `update` over MCP). Each change is in the
487487 [audit log](/guides/audit-log/) as `workspace.member_privileges_changed`.
488488
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+
489502 ## Require two-factor authentication
490503
491504 An owner can require everyone with access to the workspace, its members
718731 | | **Billing and plans** | Members | The plan, the spend limit, prepaying and every charge. `-/billing` |
719732 | **Compute** | [**Runners**](/guides/self-hosted-runners/) | Owners | The workspace's self-hosted machines, their groups and registration tokens. `-/runners` |
720733 | **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` |
722735 | | [**Secrets and variables**](/guides/secrets-and-variables/) | Members | What runs and deployments are given. Owners change them. `-/secrets` |
723736 | | [**Webhooks**](/guides/webhooks/) | Members | Where the workspace's events are sent. Owners add and change them. `-/webhooks` |
724737 | | **Deployments**, **Project defaults** | | Soon: defaults for every project's deployments, and what a new project starts with. |
+4−0
476476 | [`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` |
477477 | [`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` |
478478 | [`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` |
479483 | [`get_approval_policy`](/reference/api/run-protection/get-fork-pr-approval/) | Which pull requests' runs wait for approval. | `repo` | `repo:read` |
480484 | [`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` |
481485 | [`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` |
+0−0

Binary or large file; its contents are not shown.

+31−5
99 import { Duration, Notes, StatusIcon, shortRef } from "../../components/actions";
1010 import { AddCiPrompt } from "../../components/add-ci";
1111 import { ComputeNote, CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
12+import { Badge } from "../../components/ui/badge";
1213 import { Button } from "../../components/ui/button";
1314 import { Card } from "../../components/ui/card";
1415 import { useWorkflowReason } from "../../components/mirror";
304305 <div className="min-w-0">
305306 <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3>
306307 {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} />
311309 ) : (
312310 <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg">
313311 <FileCode2 size={12} />
322320 <div className="flex flex-wrap items-center gap-2">
323321 <StatusBadge workflow={workflow} repo={base.slice(1)} />
324322 {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">
326324 <input type="hidden" name="intent" value="toggle" />
327325 <input type="hidden" name="workflow" value={workflow.id} />
328326 <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} />
346344 );
347345 }
348346
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+
349370 const EXAMPLE = `name: CI
350371 on:
351372 push:
434455 <span className="block truncate font-mono text-[0.6875rem] text-faint">{w.path}</span>
435456 )}
436457 </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+ )}
437463 {w.error && <AlertTriangle size={12} className="shrink-0 text-danger" aria-label="Its file has a problem" />}
438464 {w.state === "disabled" && <span className="ml-auto shrink-0 text-xs text-faint">off</span>}
439465 </Link>
+284−7
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";
24
3−import type { WorkspaceActionsSettings } from "@g1t/contracts";
5+import type { WorkspaceActionsSettings, WorkspaceManagedWorkflow } from "@g1t/contracts";
46
57 import type { Route } from "./+types/actions-settings";
68 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";
823 import { CheckboxOption } from "../../components/ui/checkbox";
24+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog";
925 import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
26+import { SelectField } from "../../components/ui/select";
1027 import { page } from "../../lib/meta";
1128 import { actions } from "../../lib/services.server";
1229 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
2037 // Members may see the policy; owners change it.
2138 const role = roleIn(viewer, params.owner);
2239 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" };
2545 }
2646
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+
2775 export async function action({ request, params, context }: Route.ActionArgs) {
2876 assertSameOrigin(request);
2977 if (roleIn(getViewer(context), params.owner) !== "owner") throw new Response(null, { status: 404 });
3078 const user = requireUser(context, request);
3179 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+ }
3283 const level = (name: string): "read" | "write" => (form.get(name) === "write" ? "write" : "read");
3384 const change: Partial<WorkspaceActionsSettings> = {
3485 defaultPermissions: level("defaultPermissions"),
3990 return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message };
4091 }
4192
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]);
44110 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/&lt;name&gt;.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 (
45311 <div className="space-y-6">
46312 <header>
47313 <h1 className="text-xl font-semibold tracking-tight">Actions</h1>
106372 <p className="text-sm text-muted">Only the workspace's owners can change these.</p>
107373 )}
108374 </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+ )}
109386 </div>
110387 );
111388 }
+220−14
55 //! [`FOLDER`] that no file can have.
66 //!
77 //! 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.
1013 //!
1114 //! [`deploy_workflow`] writes the one deployments install: it calls g1t's
1215 //! own reusable deploy workflow with the project's settings as its inputs.
117120 workflow::parse(source).map(|parsed| parsed.display_name(path)).unwrap_or_else(|_| path.to_owned())
118121 }
119122
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
126251 }
127252
128253 #[cfg(test)]
192317 assert_eq!(with["root-dir"], ".");
193318 }
194319
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+
195328 #[test]
196329 fn a_repository_workflow_of_the_same_name_takes_its_place() {
197330 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));
204337 assert!(is_managed(&deploy.0) && !is_managed(".g1t/workflows/g1t.yml") && !is_managed("managedx/a.yml"));
205338 }
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+ }
206412 }
+98−0
4747 /// `on.workflow_dispatch.inputs` as written, when it can be run by hand.
4848 pub dispatch: Option<Value>,
4949 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>,
5053 }
5154
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+
5271 #[derive(Clone, Debug, Serialize, Deserialize)]
5372 #[serde(rename_all = "camelCase")]
5473 pub struct WorkflowRun {
803822 pub production: bool,
804823 }
805824
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+
806904 /// `remove_managed_workflow`: takes a managed workflow out of a repository.
807905 /// Its runs stay. Returns `bool`, whether there was one.
808906 #[derive(Debug, Serialize, Deserialize)]
+5−0
11971197 // A workspace's policy for its repositories' tokens.
11981198 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
11991199 ("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),
12001205 // A workspace's rules for personal access tokens, and the members'
12011206 // tokens that reach it: who has access.
12021207 ("get_token_policy", Scope::WorkspaceRead),
+58−0
6161 notes: WorkflowNote[];
6262 dispatch: Record<string, DispatchInput> | null;
6363 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;
64101 };
65102
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+
66112 export type StepState = {
67113 number: number;
68114 name: string;
386432 workspace: string,
387433 change: Partial<WorkspaceActionsSettings>,
388434 ): 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>>;
389447 /** Every environment the repository's rules, secrets, workflows or jobs name. */
390448 environments(repo: RepoPath, viewer: Viewer): Promise<Result<Environment[]>>;
391449 /** 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…