Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Separate workspace variables and show project overrides

syntaqxcommitted Parentafa7be5Browse files
5 files+108−540/5 viewed
+57−46
11 /**
2− * A project's or a workspace's secrets and variables, as one list in the
3− * way Vercel lists environment variables: each row is a key, its type
2+ * A project's or a workspace's secrets and variables, separated by scope:
3+ * each row is a key, its type
44 * (Secret or Config), the environments it applies to and who reads it.
55 * Adding and editing happen in a side panel, opened by `?add` or
66 * `?edit=<id>` so the page works without scripts.
1111
1212 import type { Setting } from "@g1t/contracts";
1313
14+import { settingOverride } from "../lib/setting-overrides";
15+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
16+
1417 import type { SecretsAction, SecretsData } from "../lib/secrets.server";
1518 import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
1619 import { Badge } from "./ui/badge";
3841 action,
3942 scope,
4043 manage,
44+ workspacePath,
4145 }: {
4246 data: SecretsData;
4347 action: SecretsAction | undefined;
4448 scope: "project" | "workspace";
4549 manage: boolean;
50+ workspacePath?: string;
4651 }) {
4752 const location = useLocation();
4853 const params = new URLSearchParams(location.search);
6469 );
6570
6671 return (
67− <div className="max-w-5xl">
72+ <div className="min-w-0 max-w-5xl">
6873 <header className="flex flex-wrap items-start justify-between gap-4">
6974 <div>
7075 <h1 className="text-lg font-semibold tracking-tight">{scope === "project" ? "Environment variables" : "Secrets and variables"}</h1>
7176 <p className="mt-1 max-w-2xl text-sm text-muted">
72− One list for everything that reads them. Each row says which environments it applies to and whether{" "}
73− <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
74− and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
75− (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
77+ Secrets and configuration for workflows and deployments, scoped by environment.
7678 {scope === "workspace"
77− ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins."
78− : " Rows from the workspace are shown too; adding the same key here replaces them for this project."}{" "}
79+ ? " Share values with every project or only the projects you choose."
80+ : " Project keys override workspace keys of the same type for each shared reader."}{" "}
7981 <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline">
8082 How they are read
8183 </a>
138140 <ErrorText>{data.error}</ErrorText>
139141 {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
140142
141− <div className="mt-4">
142− {data.rows.length === 0 ? (
143− <EmptyState title="No secrets or variables yet">
144− Add one, or paste a <code>.env</code> file into Add to bring many at once.
145− </EmptyState>
146− ) : shown.length === 0 ? (
147− <EmptyState title="Nothing matches" />
148− ) : (
149− <Card asChild className="overflow-hidden">
150− <ul>
151− {shown.map((r) => (
152− <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
153− ))}
154− </ul>
155− </Card>
156− )}
143+ <div className="mt-6 space-y-8">
144+ {(scope === "project" ? ["project", "workspace"] as const : ["workspace"] as const).map((section) => {
145+ const rows = shown.filter((r) => r.scope === section);
146+ const total = data.rows.filter((r) => r.scope === section).length;
147+ return (
148+ <section key={section} aria-label={`${section === "project" ? "Project" : "Workspace"} secrets and variables`}>
149+ <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
150+ <div>
151+ <h2 className="text-sm font-semibold">{section === "project" ? "Project secrets and variables" : "Workspace secrets and variables"}</h2>
152+ {scope === "project" && section === "workspace" && <p className="mt-1 text-xs text-muted">Inherited values shared with this project. A project key replaces the workspace value for its readers, even outside its selected environments.</p>}
153+ </div>
154+ {scope === "project" && section === "workspace" && workspacePath && <ButtonLink to={workspacePath} variant="outline">View workspace variables</ButtonLink>}
155+ </div>
156+ {rows.length === 0 ? (
157+ <EmptyState title={total > 0 ? "Nothing matches these filters" : section === "project" ? "No project secrets or variables" : "No workspace secrets or variables"} />
158+ ) : (
159+ <Card className="min-w-0 overflow-hidden">
160+ <Table className="min-w-[850px] table-fixed" frameProps={{ tabIndex: 0, role: "region", "aria-label": `${section} secrets and variables` }}>
161+ <colgroup><col style={{ width: "32%" }} /><col style={{ width: "19%" }} /><col style={{ width: "18%" }} /><col style={{ width: "10%" }} /><col style={{ width: "11%" }} /><col style={{ width: "10%" }} /></colgroup>
162+ <TableHeader><TableRow>
163+ <TableHead>Name / value</TableHead><TableHead>Environments</TableHead><TableHead>Used by</TableHead><TableHead>Type</TableHead><TableHead>Updated</TableHead><TableHead><span className="sr-only">Actions</span></TableHead>
164+ </TableRow></TableHeader>
165+ <TableBody>{rows.map((r) => <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} override={scope === "project" ? settingOverride(r, data.rows) : null} />)}</TableBody>
166+ </Table>
167+ </Card>
168+ )}
169+ </section>
170+ );
171+ })}
157172 </div>
158173
159174 {manage && (adding || row) && (
164179 );
165180 }
166181
167−function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
182+function Row({ row, inherited, manage, override }: { row: Setting; inherited: boolean; manage: boolean; override: string | null }) {
168183 const secret = row.kind === "secret";
169184 const hosts = row.brokeredHosts ?? [];
170185 return (
171− <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]">
186+ <TableRow>
187+ <TableCell className="align-middle">
172188 <div className="min-w-0">
173189 <div className="flex min-w-0 items-center gap-2">
174− <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
190+ <p className="min-w-0 break-all font-mono text-[0.8125rem]">{row.name}</p>
175191 {secret && hosts.length > 0 && (
176192 <Hint label={`Filled in for ${hosts.join(", ")}`}>
177193 <Badge tone="accent">
181197 </Hint>
182198 )}
183199 </div>
184− {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
200+ {row.note && <p className="truncate text-xs text-faint" title={row.note}>{row.note}</p>}
201+ {override && <p className="mt-1 text-xs text-accent">{override}</p>}
185202 {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
186203 </div>
187− <span className="truncate text-muted">{environmentsLabel(row.environments)}</span>
188− <span className="hidden truncate text-xs text-muted md:block">
189− {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")}
190− </span>
191− <span className="hidden items-center gap-1.5 text-xs text-muted md:flex">
192− {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />}
193− {secret ? "Secret" : "Config"}
194− </span>
195− <span className="hidden text-xs text-faint md:block">
196− <TimeAgo at={row.updatedAt} />
197− </span>
198− <span className="flex items-center justify-end gap-1">
199− {inherited ? (
200− <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
201− ) : (
204+ </TableCell>
205+ <TableCell className="align-middle break-words text-muted">{environmentsLabel(row.environments)}</TableCell>
206+ <TableCell className="align-middle text-xs text-muted">{row.availableTo.map((r) => READERS[r] ?? r).join(", ")}</TableCell>
207+ <TableCell className="align-middle text-xs text-muted"><span className="flex items-center gap-1.5">
208+ {secret ? <Lock size={13} className="shrink-0" /> : <SlidersHorizontal size={13} className="shrink-0" />}{secret ? "Secret" : "Config"}
209+ </span></TableCell>
210+ <TableCell className="align-middle text-xs text-faint"><TimeAgo at={row.updatedAt} /></TableCell>
211+ <TableCell className="align-middle"><div className="flex flex-wrap items-center justify-end gap-1">
212+ {!inherited && (
202213 <>
203214 {row.projects.length > 0 && (
204215 <Hint label={row.projects.join(", ")}>
236247 )}
237248 </>
238249 )}
239− </span>
240− </li>
250+ </div></TableCell>
251+ </TableRow>
241252 );
242253 }
243254
+29−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+import type { Setting } from "@g1t/contracts";
4+import { settingOverride } from "./setting-overrides.ts";
5+
6+const workspace: Setting = { id: "w", name: "TOKEN", kind: "secret", scope: "workspace", value: null, updatedAt: "", environments: [], availableTo: ["workflows", "deployments"], projects: [] };
7+const project: Setting = { ...workspace, id: "p", scope: "project" };
8+
9+test("full overrides remain visible on both scopes", () => {
10+ assert.equal(settingOverride(workspace, [workspace, project]), "Overridden by project");
11+ assert.match(settingOverride(project, [workspace, project])!, /^Overrides workspace/);
12+});
13+
14+test("environment-restricted project keys still suppress workspace fallback", () => {
15+ assert.equal(settingOverride(workspace, [workspace, { ...project, environments: ["production"] }]), "Overridden by project");
16+ assert.equal(settingOverride({ ...workspace, environments: ["production"] }, [workspace, { ...project, environments: ["preview"] }]), "Overridden by project");
17+});
18+
19+test("only shared readers are overridden", () => {
20+ assert.equal(settingOverride(workspace, [workspace, { ...project, availableTo: ["workflows"] }]), "Partially overridden: Workflows");
21+ assert.equal(settingOverride(workspace, [workspace, { ...project, availableTo: ["workflows"] }, { ...project, availableTo: ["deployments"] }]), "Overridden by project");
22+});
23+
24+test("different names, kinds and readers do not override", () => {
25+ const scoped = { ...workspace, availableTo: ["workflows"] };
26+ for (const other of [{ ...project, name: "OTHER" }, { ...project, kind: "variable" }, { ...project, availableTo: ["deployments"] }]) {
27+ assert.equal(settingOverride(scoped, [scoped, other]), null);
28+ }
29+});
+12−0
1+import type { Setting } from "@g1t/contracts";
2+
3+/** Project keys suppress workspace values per kind and reader, even when
4+ * none of the project's rows applies to the requested environment. */
5+export function settingOverride(row: Setting, rows: Setting[]): string | null {
6+ const others = rows.filter((other) => other.scope !== row.scope && other.name === row.name && other.kind === row.kind);
7+ const readers = row.availableTo.filter((reader) => others.some((other) => other.availableTo.includes(reader)));
8+ if (readers.length === 0) return null;
9+ const labels = readers.map((reader) => reader === "workflows" ? "Workflows" : reader === "deployments" ? "Deployments" : reader).join(", ");
10+ if (row.scope === "project") return `Overrides workspace: ${labels}`;
11+ return readers.length === row.availableTo.length ? "Overridden by project" : `Partially overridden: ${labels}`;
12+}
+7−4
22 import { page } from "../../lib/meta";
33 import { SecretsPanel } from "../../components/secrets";
44 import { actOnSecrets, loadSecrets } from "../../lib/secrets.server";
5−import { assertSameOrigin, requireUser } from "../../lib/session.server";
5+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
66 import { requireCapability, requireInsider } from "../../lib/access.server";
77
88 export function meta({ params, ...args }: Route.MetaArgs) {
1414 export async function loader({ params, context, request }: Route.LoaderArgs) {
1515 // Admins; to anyone without a role here the page does not exist.
1616 await requireInsider(context, params, "manage_integrations");
17− return loadSecrets(ownerOf(params), requireUser(context, request));
17+ return {
18+ ...(await loadSecrets(ownerOf(params), requireUser(context, request))),
19+ workspacePath: roleIn(getViewer(context), params.owner) ? `/${params.owner}/-/secrets` : undefined,
20+ };
1821 }
1922
2023 export async function action({ request, params, context }: Route.ActionArgs) {
2730
2831 export default function RepoSecrets({ loaderData, actionData }: Route.ComponentProps) {
2932 return (
30− <div>
31− <SecretsPanel data={loaderData} action={actionData} scope="project" manage />
33+ <div className="min-w-0">
34+ <SecretsPanel data={loaderData} action={actionData} scope="project" manage workspacePath={loaderData.workspacePath} />
3235 </div>
3336 );
3437 }
+3−4
316316 let kind = check!(Self::kind(&a.kind));
317317 let place = check!(self.place(&a.actor, &a.owner, false).await?);
318318 let mut out: Vec<Setting> = Vec::new();
319− // A project's list shows the workspace's rows that reach it, but for
320− // keys it sets itself.
319+ // Keep inherited rows, including overrides: the UI needs both scopes
320+ // to explain precedence per environment and reader.
321321 if place.scope == "project" {
322− let own: Vec<String> = self.rows(&place.owner).await?.into_iter().map(|row| row.name).collect();
323322 for row in self.rows(&place.namespace.to_lowercase()).await? {
324− if row.reaches(&place.slug) && !own.contains(&row.name) {
323+ if row.reaches(&place.slug) {
325324 out.push(row.describe());
326325 }
327326 }

Comments

Sign in to comment.