Skip to content

Commit

A workspace's owners manage its workflows under Workspace, Code, Actions: **Workflows this workspace manages** lists each with its mode, the file and commit it was copied from, the repositories it runs in and who last saved it, and **Add workflow**, **Edit**, **Copy again** and **Remove** change them, the source, mode, repositories (all, or names and patterns one per line) and visibility in one dialog; members see that only owners can. A repository's Actions page marks a workspace's workflow with the workspace's name in the list and says on its page who manages it, from which file, and whether a workflow of its name in `.g1t/workflows/` takes its place; a required one has no **Turn off**. The Actions guide has a section, Workflows a workspace manages, with the modes, targets, why the source is copied, the private-source rule, requiring its check in a ruleset, and the API, and the Workspaces guide points to it.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parentd627547Browse files
4 files+397−130/4 viewed
+68−0
183183 A workflow of the same `name:` in `.g1t/workflows/` runs in its place, so
184184 to change one, commit your own version of it.
185185
186+## Workflows a workspace manages
187+
188+A workspace's owners can run a workflow in the workspace's repositories
189+without committing it to each one, as GitHub's required workflows do. It
190+runs in each repository it targets as that repository's own workflow: on
191+the repository's events, with the repository's job token, secrets and
192+variables, and reporting the repository's checks. Its path is
193+`managed/<name>.yml`, and the repository's **Actions** page lists it as
194+managed by the workspace, with the file it came from.
195+
196+To add one:
197+
198+1. Commit the workflow to one of the workspace's repositories, such as
199+ `acme/policies/.g1t/workflows/scan.yml`.
200+2. Open **Workspace → Code → Actions**, `g1t.sh/<workspace>/-/actions`, and
201+ under **Workflows this workspace manages** choose **Add workflow**.
202+3. Give it a name, its source as `owner/repo/path@ref` (for example
203+ `acme/policies/.g1t/workflows/scan.yml@main`), a mode, and the
204+ repositories it runs in, then choose **Add workflow**.
205+
206+| Setting | Options |
207+| --- | --- |
208+| 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. |
209+| 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. |
210+| Visibility | Public and private repositories, public only, or private only. |
211+
212+g1t copies the source file when you save, at the commit its ref names
213+then, and that copy runs until you copy it again with **Copy again** (or
214+save it with another source). So someone who can push to the source
215+repository cannot change what runs with every repository's secrets: only
216+an owner can, by copying it. The workflows list shows the commit each was
217+copied at.
218+
219+- The source must be a workflow file in one of the workspace's own
220+ repositories.
221+- A source in a private repository runs in private repositories only, as a
222+ public repository's workflows cannot use a private repository's: choose
223+ **Private repositories only**, or g1t refuses it. A repository made
224+ public later stops running it.
225+- `uses: ./…` in the copy names the repository it runs in, as it would in a
226+ file committed there. To call a reusable workflow of the source
227+ repository, name it in full:
228+ `uses: acme/policies/.g1t/workflows/build.yml@main`, which follows the
229+ [rules for other repositories](#actions-and-workflows-from-other-repositories).
230+- A required workflow runs for pull requests from forks and in the
231+ [merge queue](/guides/merge-queue/) too, whatever the fork's files say.
232+- When it changes, up to 20 repositories' workflow lists are brought up to
233+ date at once. In a larger workspace the rest follow within minutes, or
234+ as soon as someone opens their Actions page; their runs read the
235+ workflows as they are meanwhile.
236+
237+To make a pull request wait for a required workflow, require its check in
238+a [ruleset](/guides/rules/): its `name:` as the context, with
239+`integration: actions`. A failure of the workflow fails the check whatever
240+else reports a check of that name.
241+
242+Through the API and MCP:
243+
244+| Route | MCP | |
245+| --- | --- | --- |
246+| [`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. |
247+| [`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`). |
248+| [`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. |
249+| [`DELETE /workspaces/{workspace}/actions/managed-workflows/{workflow}`](/reference/api/run-protection/delete-workspace-managed-workflow/) | `workflow` `remove_managed_workflow` | Its runs stay. |
250+
251+Only owners can see and change them; a token needs `workspace:read` to
252+list them and `workspace:admin` to change them.
253+
186254 ## Schedules
187255
188256 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. |
+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 }