Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

33 files+1545−4210/33 viewed
+45−11
576576 }
577577 Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Members only.",
578578 Op::ListActionsSecrets => {
579− "The names of the secrets workflows read as `secrets.NAME`: a repository's, with the ones it inherits from its workspace, or a workspace's. Values are never returned. Members only."
579+ "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, and whether workflows (`secrets.NAME`), deployments, or both read it. Values are never returned. Members only."
580580 }
581581 Op::SetActionsSecret => {
582− "Add or replace a secret. A repository's need a member; a workspace's, which every repository in it reads, an owner. Names are letters, digits and underscores, upper-cased."
582+ "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `availableTo` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. A repository's need a member; a workspace's an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
583583 }
584− Op::DeleteActionsSecret => "Remove a secret.",
584+ Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.",
585585 Op::ListActionsVariables => {
586− "The variables workflows read as `vars.NAME`, with their values: a repository's, with the ones it inherits from its workspace, or a workspace's. Members only."
586+ "The variables (Config) of a repository, with the workspace's rows that reach it, or of a workspace, with their values: each row's key, environments and readers (workflows read them as `vars.NAME`). Members only."
587587 }
588− Op::SetActionsVariable => "Add or replace a variable, as for secrets.",
589− Op::DeleteActionsVariable => "Remove a variable.",
588+ Op::SetActionsVariable => "Add or change a variable's row, as for secrets.",
589+ Op::DeleteActionsVariable => "Remove a variable: one row by `id`, or every row of the key.",
590590 Op::ImportIssue => {
591591 "Open an issue from a ticket in Jira or Linear, or from a Sentry issue, by its key or address. The issue is linked to it: agents read the original, and when the work lands the ticket is told. Importing the same ticket again returns the issue already made. With assign, a g1t agent starts on it."
592592 }
986986 Op::ListActionsSecrets | Op::ListActionsVariables => object(settings_owner(json!({})), &[]),
987987 Op::SetActionsSecret | Op::SetActionsVariable => object(
988988 settings_owner(json!({
989− "setting": { "type": "string", "description": "The name, such as NPM_TOKEN." },
990− "value": { "type": "string" },
989+ "setting": { "type": "string", "description": "The key, such as NPM_TOKEN." },
990+ "value": { "type": "string", "description": "Needed for a new row; left out, the row keeps its value." },
991+ "id": { "type": "string", "description": "The row to change, from a list. Left out: the key's row for every environment." },
992+ "availableTo": {
993+ "type": "array",
994+ "items": { "type": "string", "enum": ["workflows", "deployments"] },
995+ "description": "Who reads it. Both for a new row."
996+ },
997+ "environments": {
998+ "type": "array",
999+ "items": { "type": "string" },
1000+ "description": "The environments it applies to, such as production and preview, or a workflow job's environment. Empty is every environment."
1001+ },
1002+ "repositories": {
1003+ "type": "array",
1004+ "items": { "type": "string" },
1005+ "description": "A workspace's row: the repositories it reaches, by name. Empty is every one."
1006+ },
1007+ "note": { "type": "string", "description": "Where to rotate it, or who to ask." },
9911008 })),
992− &["setting", "value"],
1009+ &["setting"],
9931010 ),
9941011 Op::DeleteActionsSecret | Op::DeleteActionsVariable => object(
995− settings_owner(json!({ "setting": { "type": "string", "description": "The name." } })),
1012+ settings_owner(json!({
1013+ "setting": { "type": "string", "description": "The key." },
1014+ "id": { "type": "string", "description": "One row; left out, every row of the key." },
1015+ })),
9961016 &["setting"],
9971017 ),
9981018 Op::CreateWebhook => object(
17091729 args["kind"] = json!(kind);
17101730 // GitHub's variables API names the variable in the body as `name`.
17111731 args["name"] = json!(optional_text(input, "setting").or_else(|| optional_text(input, "name")).unwrap_or_default());
1712− args["value"] = json!(text(input, "value"));
1732+ // GitHub's routes send a value every time; ours may leave it
1733+ // out to change only where a row applies.
1734+ if let Some(value) = input["value"].as_str() {
1735+ args["value"] = json!(value);
1736+ }
1737+ for key in ["availableTo", "environments", "repositories"] {
1738+ if let Some(list) = strings(input, key) {
1739+ args[key] = json!(list);
1740+ }
1741+ }
1742+ for key in ["id", "note"] {
1743+ if let Some(value) = input[key].as_str() {
1744+ args[key] = json!(value);
1745+ }
1746+ }
17131747 let method = match self {
17141748 Op::ListActionsSecrets | Op::ListActionsVariables => "settings",
17151749 Op::SetActionsSecret | Op::SetActionsVariable => "set_setting",
+1−0
8484 { label: 'Model providers', slug: 'guides/models' },
8585 { label: 'Webhooks', slug: 'guides/webhooks' },
8686 { label: 'GitHub Actions', slug: 'guides/actions' },
87+ { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' },
8788 ],
8889 },
8990 {
+20−16
3737 | `run:` with `bash`, `sh`, `python` or a custom shell | The same. |
3838 | JavaScript actions (`uses: owner/repo@v7`) | Fetched from GitHub and run as they are, on Node 24, the runtime current actions declare. |
3939 | Composite actions | The same. |
40−| Reusable workflows in the repository (`jobs.<id>.uses: ./.g1t/workflows/build.yml`) | The same: `with:` inputs, `on.workflow_call` outputs, and nesting up to four deep. `./.github/workflows/…` finds the workflow under `.g1t/` after the move. Their jobs run with the repository's secrets. |
40+| Reusable workflows in the repository (`jobs.<id>.uses: ./.g1t/workflows/build.yml`) | The same: `with:` inputs, `on.workflow_call` outputs, and nesting up to four deep. `./.github/workflows/…` finds the workflow under `.g1t/` after the move. Their jobs read the repository's secrets and variables. |
4141 | `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. |
4242 | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. |
4343 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. |
44−| `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same; `GITHUB_TOKEN` is a token for g1t. |
44+| `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is the workspace's own token for the run; `GITHUB_TOKEN` is its alias. |
45+| `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work. |
4546 | `actions/upload-artifact`, `actions/download-artifact` | Kept with the run for 14 days, passed between its jobs, and downloadable from the run's page. Up to 60 MB each. |
4647 | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository for 7 days, found by `key` or the newest under a `restore-keys` prefix. Up to 60 MB each. |
4748
5758 - **The toolkit's own cache.** Actions that cache through GitHub's service
5859 themselves, such as `actions/setup-node` with `cache: npm`, run without
5960 it. Use `actions/cache` for the same effect.
60−- **Environments' protection rules**. A job with `environment:` runs with
61− the repository's secrets.
61+- **Environments' protection rules** (required reviewers, wait timers,
62+ branch limits). A job with `environment:` gets that environment's
63+ [values](/guides/secrets-and-variables/#a-value-per-environment), and runs
64+ without waiting.
6265
6366 ## The runner
6467
105108
106109 ## Secrets and variables
107110
108−Secrets are read as `${{ secrets.NAME }}` and variables as
109−`${{ vars.NAME }}`. Set them under **Settings → Secrets and variables**:
110−
111−- a repository's, which its members manage;
112−- a workspace's, which owners manage and every repository reads. A
113− repository's own of the same name wins.
111+Secrets are read as `${{ secrets.KEY }}` and config as `${{ vars.KEY }}`,
112+from the rows under **Settings → Secrets and variables** that are
113+available to Workflows. A job with `environment: production` reads each
114+key's Production row; other jobs read the rows for all environments. See
115+[Secrets and variables](/guides/secrets-and-variables/) for how rows,
116+environments and the workspace's rows work.
114117
115−Secret values are sealed when saved and never shown again. Pull requests
116−from people outside the workspace run without secrets, and with a
117−`GITHUB_TOKEN` that cannot write.
118+Every trusted job also gets `${{ secrets.G1T_TOKEN }}`, the workspace's own
119+token for the run, with `GITHUB_TOKEN` as its alias. Pull requests from
120+people outside the workspace run without secrets, and with an empty
121+token.
118122
119123 ## Who may run workflows
120124
144148 | `list_actions_variables`, `set_actions_variable`, `delete_actions_variable` | `GET` and `POST /repos/{owner}/{repo}/actions/variables`, `PATCH` and `DELETE …/variables/{name}` |
145149
146150 Workspace secrets and variables are under
147−`/workspaces/{workspace}/actions/secrets` and `…/variables`. Unlike
148−GitHub's, a secret is sent as plain `value` over HTTPS, not encrypted to a
149−public key.
151+`/workspaces/{workspace}/actions/secrets` and `…/variables`. The fields
152+g1t adds (environments, who reads a row, linked repositories) are in
153+[Secrets and variables](/guides/secrets-and-variables/#from-the-api).
150154
151155 ```sh
152156 curl -X POST https://api.g1t.sh/repos/acme/web/actions/workflows/ci.yml/dispatches \
+28−5
7171 - Your Worker's `fetch` handler runs as written, and its static assets
7272 are served under the binding name your config gives them. Cron triggers
7373 in the config are not scheduled.
74−- `vars` are deployed as plain-text bindings (or JSON, for objects).
74+- `vars` are deployed as plain-text bindings (or JSON, for objects). Rows
75+ of the repository's [secrets and variables](/guides/secrets-and-variables/)
76+ available to Deployments are bound too, and replace a `var` of the same
77+ name: secrets as secret bindings.
7578 - **Not provisioned yet:** D1, KV, R2, Durable Objects, Queues, service
7679 bindings, Vectorize, Hyperdrive, Workers AI and Workflows. A project that
7780 declares any of them still deploys, without them, and its deployment
120123
121124 ## Settings
122125
123−On the repository's **Deployments** page, under **Settings**:
126+Under the repository's **Settings → Deployments**,
127+`g1t.sh/<workspace>/<repo>/settings/deployments`:
124128
125129 | Setting | Default | |
126130 | --- | --- | --- |
129133 | Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. |
130134 | Output directory | Found by itself | What a static site serves. |
131135 | Idle days | 7 | 1 to 90. A preview no one visits this long comes down. |
132−| Build variables | None | `KEY=value` lines the build runs with, such as `NODE_ENV=production`. Up to 50. They are shown to every member, so keep keys and tokens out of them. |
133136
137+## Secrets and variables
138+
139+Builds and running apps read the repository's
140+[secrets and variables](/guides/secrets-and-variables/) that are
141+available to Deployments, and the workspace's that reach it:
142+
143+| | Reads |
144+| --- | --- |
145+| A production build, and production | Each key's Production row, else its row for all environments. |
146+| A preview build, and the preview | Each key's Preview row, else its row for all environments. |
147+
148+The build gets them as environment variables, with secrets hidden in its
149+log. The running app gets them as bindings, `env.KEY`, put in place by g1t
150+rather than the build. A preview of a pull request from outside the
151+workspace is built and runs with config only, no secrets.
152+
153+For example, a `STRIPE_KEY` secret with a Production row holding the live
154+key and a Preview row holding the test key gives every preview the test
155+key.
156+
134157 ## What it costs
135158
136159 Deployments are never free, including while the rest of g1t is.
175198
176199 ## Turn it off
177200
178−- **For a repository:** **Turn off deployments** at the bottom of its
179− Deployments page. Every app comes down at once. Turning it on again
201+- **For a repository:** **Turn off deployments** under its **Settings →
202+ Deployments**. Every app comes down at once. Turning it on again
180203 rebuilds production.
181204 - **For the workspace:** an owner chooses **Turn off at the end of the
182205 period** under the plan on Billing. Deployments keep working until the
+134−0
1+---
2+title: Secrets and variables
3+description: One list of keys and values for workflows and deployments. Each row says which environments it applies to and who reads it.
4+---
5+
6+A repository and a workspace each have one list of secrets and variables.
7+Workflows and deployments both read from it; each row says whether one,
8+the other or both do, and which environments it applies to.
9+
10+| Where | Page | Who changes it |
11+| --- | --- | --- |
12+| A repository | **Settings → Secrets and variables**, `g1t.sh/<workspace>/<repo>/settings/secrets` | Members |
13+| A workspace | **Settings → Secrets and variables**, `g1t.sh/<workspace>/-/secrets` | Owners |
14+
15+## A row
16+
17+| Field | |
18+| --- | --- |
19+| **Type** | **Secret**: sealed when saved and never shown again, hidden in logs. For passwords, API keys and tokens. **Config**: readable by members. For values that are not sensitive. Config can be changed to a secret; a secret can never become config. |
20+| **Key** | Letters, digits and underscores, upper-cased: `STRIPE_KEY`. Keys starting with `G1T_` or `GITHUB_` are g1t's own. |
21+| **Value** | Up to 48 KB. |
22+| **Note** | Optional: where to rotate it, or who to ask. |
23+| **Environments** | **All environments**, or only some: **Production**, **Preview**, or any name a workflow job uses in `environment:`, such as `staging`. |
24+| **Available to** | **Workflows**, **Deployments**, or both (the default). |
25+| **Repositories** | A workspace's row only: every repository, or the ones you choose. |
26+
27+### A value per environment
28+
29+A key can have one row per environment, so production and previews use
30+different values. For example, Stripe's live key in production and its
31+test key everywhere else:
32+
33+| Key | Type | Environments | Value |
34+| --- | --- | --- | --- |
35+| `STRIPE_KEY` | Secret | Production | `sk_live_…` |
36+| `STRIPE_KEY` | Secret | Preview | `sk_test_…` |
37+
38+Two rows of the same key and type cannot apply to the same environment.
39+A key can also have a row for all environments alongside rows for some:
40+the rows for some win where they apply.
41+
42+### Adding many at once
43+
44+Paste the contents of a `.env` file into **Key** when adding. Each
45+`KEY=value` line becomes a row with the type, environments and readers you
46+choose; blank lines and `#` comments are skipped.
47+
48+## Who reads what
49+
50+| Reader | Reads | Environment it asks for |
51+| --- | --- | --- |
52+| A workflow job | Rows available to Workflows: secrets as `${{ secrets.KEY }}`, config as `${{ vars.KEY }}` | The job's `environment:`, if it has one |
53+| A deploy build | Rows available to Deployments, as environment variables | `production` or `preview` |
54+| A running app | The same rows, as bindings: `env.KEY` (a secret as a secret binding) | `production` or `preview` |
55+| An agent, acceptance checks, the merge queue | Nothing | |
56+
57+For each key, a reader gets the row for its environment if there is one,
58+else the row for all environments. A row only for other environments gives
59+it nothing.
60+
61+A repository's row overrides its workspace's of the same key. The
62+repository's list shows the workspace's rows that reach it, marked
63+**Workspace**, until it sets the key itself.
64+
65+A running app's rows are bound by g1t when it puts the app up; they never
66+pass through the build's sandbox. A row of a Workers project's own
67+`vars` with the same name is replaced.
68+
69+## Who gets secrets
70+
71+Secrets go only to trusted runs:
72+
73+- pushes, schedules, manual runs and the merge queue;
74+- pull requests from members of the workspace and from g1t's agents;
75+- every pull request on a private repository.
76+
77+A pull request from someone outside the workspace runs its workflows, and
78+builds its preview, with config only: no secrets, and an empty `G1T_TOKEN`.
79+
80+## G1T_TOKEN
81+
82+Every trusted workflow job gets `${{ secrets.G1T_TOKEN }}`: a token of the
83+workspace's own for the run, which acts on g1t as the workspace and expires
84+when the job could no longer be running. `${{ secrets.GITHUB_TOKEN }}` and
85+`${{ github.token }}` are the same token, so workflows written for GitHub
86+work unchanged.
87+
88+```yaml
89+- name: Open an issue when the nightly build fails
90+ if: failure()
91+ run: |
92+ curl -X POST https://api.g1t.sh/repos/${{ github.repository }}/issues \
93+ -H "Authorization: Bearer ${{ secrets.G1T_TOKEN }}" \
94+ -d '{"title":"Nightly build failed"}'
95+```
96+
97+`G1T_TOKEN` can read secrets' names but never change secrets or variables,
98+so a workflow cannot rewrite what it runs with. Neither can any workspace
99+access token: use a person's token, or the site.
100+
101+## From the API
102+
103+The routes are GitHub's, and calls written for GitHub work unchanged: a
104+key named without an `id` or `environments` is the key's row for all
105+environments.
106+
107+| Tool | Route |
108+| --- | --- |
109+| `list_actions_secrets` | `GET /repos/{owner}/{repo}/actions/secrets` |
110+| `set_actions_secret` | `PUT /repos/{owner}/{repo}/actions/secrets/{key}` |
111+| `delete_actions_secret` | `DELETE /repos/{owner}/{repo}/actions/secrets/{key}` |
112+| `list_actions_variables` | `GET /repos/{owner}/{repo}/actions/variables` |
113+| `set_actions_variable` | `POST /repos/{owner}/{repo}/actions/variables`, `PATCH …/variables/{key}` |
114+| `delete_actions_variable` | `DELETE /repos/{owner}/{repo}/actions/variables/{key}` |
115+
116+A workspace's are under `/workspaces/{workspace}/actions/secrets` and
117+`…/variables`. Beyond GitHub's fields, a row takes:
118+
119+| Field | |
120+| --- | --- |
121+| `id` | The row to change or remove, from a list. |
122+| `availableTo` | `["workflows"]`, `["deployments"]` or both. |
123+| `environments` | `["production"]`, `["preview", "staging"]`; `[]` for all. |
124+| `repositories` | A workspace's row: repository names; `[]` for every one. |
125+| `note` | Where to rotate it, or who to ask. |
126+
127+```sh
128+curl -X PUT https://api.g1t.sh/repos/acme/web/actions/secrets/STRIPE_KEY \
129+ -H "Authorization: Bearer $YOUR_TOKEN" \
130+ -d '{"value":"sk_live_…","environments":["production"],"availableTo":["deployments"]}'
131+```
132+
133+Unlike GitHub's, a secret is sent as plain `value` over HTTPS, not
134+encrypted to a public key.
+7−2
6060 | --- | --- | --- |
6161 | **General** | Owners | The display name and a one-line description. |
6262 | **Members** | Members | Who belongs, and their roles. Owners add and remove people. |
63−| **Billing** | Members | The balance and statement. Owners add credit. |
64−| **Integrations** | Members | [Model providers, alerts and trackers](/guides/integrations/). Owners connect and remove them. |
6563 | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
64+| **Billing and plans** | Members | [Plans](/guides/usage-and-billing/#plans), the balance and the statement. Owners turn plans on and add credit. |
65+| **Integrations** | Members | [Model providers, alerts and trackers](/guides/integrations/). Owners connect and remove them. |
66+| **Secrets and variables** | Members | [Rows every repository, or the ones linked, reads](/guides/secrets-and-variables/). Owners change them. |
67+| **Webhooks** | Members | [Every repository's events](/guides/webhooks/), sent to your addresses. Owners manage them. |
68+
69+A repository's own settings are under **Settings** in its sidebar:
70+**General**, **Deployments**, **Secrets and variables** and **Webhooks**.
6671
6772 The arrow at the top of the settings goes back.
6873
+6−6
146146 | `cancel_workflow_run` | `repo`, `id` | Cancel a run. Members only. | `POST /repos/{owner}/{name}/actions/runs/{id}/cancel` |
147147 | `rerun_workflow_run` | `repo`, `id` | Run it again; `failed_only` for the jobs that did not succeed. Members only. | `POST /repos/{owner}/{name}/actions/runs/{id}/rerun` |
148148 | `update_workflow` | `repo`, `workflow`, `enabled` | Turn a workflow on or off. Members only. | `PATCH /repos/{owner}/{name}/actions/workflows/{workflow}` |
149−| `list_actions_secrets` | `repo` or `workspace` | Secret names, never values. | `GET /repos/{owner}/{name}/actions/secrets` |
150−| `set_actions_secret` | `setting`, `value` | Add or replace a secret. | `PUT /repos/{owner}/{name}/actions/secrets/{name}` |
151−| `delete_actions_secret` | `setting` | Remove a secret. | `DELETE /repos/{owner}/{name}/actions/secrets/{name}` |
152−| `list_actions_variables` | `repo` or `workspace` | Variables with their values. | `GET /repos/{owner}/{name}/actions/variables` |
153−| `set_actions_variable` | `setting`, `value` | Add or replace a variable. | `POST /repos/{owner}/{name}/actions/variables` |
154−| `delete_actions_variable` | `setting` | Remove a variable. | `DELETE /repos/{owner}/{name}/actions/variables/{name}` |
149+| `list_actions_secrets` | `repo` or `workspace` | Secrets' rows: key, environments, who reads them. Never values. | `GET /repos/{owner}/{name}/actions/secrets`, `GET /workspaces/{workspace}/actions/secrets` |
150+| `set_actions_secret` | `setting` | Add or change a secret's row: `value`, and optionally `id`, `environments`, `availableTo`, `repositories`, `note`. | `PUT …/actions/secrets/{name}` |
151+| `delete_actions_secret` | `setting` | Remove one row (`id`) or every row of the key. | `DELETE …/actions/secrets/{name}` |
152+| `list_actions_variables` | `repo` or `workspace` | Config rows with their values. | `GET …/actions/variables` |
153+| `set_actions_variable` | `setting` | Add or change a config row, as for secrets. | `POST …/actions/variables`, `PATCH …/variables/{name}` |
154+| `delete_actions_variable` | `setting` | Remove one row (`id`) or every row of the key. | `DELETE …/actions/variables/{name}` |
155155
156156 ## Messages
157157
+7−4
1−import { KeyRound, Settings, Webhook } from "lucide-react";
1+import { Lock, Rocket, Settings, Webhook } from "lucide-react";
22
33 import { TabLink } from "./ui";
44
99 <TabLink to={`${base}/settings`} end icon={<Settings size={15} />}>
1010 General
1111 </TabLink>
12− <TabLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
13− Webhooks
12+ <TabLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}>
13+ Deployments
1414 </TabLink>
15− <TabLink to={`${base}/settings/secrets`} icon={<KeyRound size={15} />}>
15+ <TabLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
1616 Secrets and variables
1717 </TabLink>
18+ <TabLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
19+ Webhooks
20+ </TabLink>
1821 </nav>
1922 );
2023 }
+388−110
11 /**
2− * A repository's or a workspace's secrets and variables, as workflows read
3− * them: `secrets.NAME` and `vars.NAME`. A repository's list includes what
4− * it inherits from its workspace, which its own of the same name replace.
2+ * A repository'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
4+ * (Secret or Config), the environments it applies to and who reads it.
5+ * Adding and editing happen in a side panel, opened by `?add` or
6+ * `?edit=<id>` so the page works without scripts.
57 */
6−import { KeyRound, Trash2, Variable } from "lucide-react";
7−import { useEffect, useRef } from "react";
8−import { Form, useNavigation } from "react-router";
8+import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
9+import { useMemo, useState } from "react";
10+import { Form, Link, useLocation, useNavigation } from "react-router";
911
10−import type { Setting, SettingKind } from "@g1t/contracts";
12+import type { Setting } from "@g1t/contracts";
1113
1214 import type { SecretsAction, SecretsData } from "../lib/secrets.server";
13−import { Button, EmptyState, ErrorText, Field, Input, Textarea, TimeAgo } from "./ui";
15+import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "./ui";
1416
15−function SettingRow({ setting, kind, manage, inherited }: { setting: Setting; kind: SettingKind; manage: boolean; inherited: boolean }) {
16− const busy = useNavigation().state === "submitting";
17− return (
18− <li className="flex items-center gap-3 border-t border-line px-4 py-2.5 first:border-t-0">
19− <span className="font-mono text-[0.8125rem]">{setting.name}</span>
20− {kind === "variable" && setting.value != null && (
21− <span className="min-w-0 truncate font-mono text-xs text-muted">{setting.value}</span>
22− )}
23− {inherited && (
24− <span className="shrink-0 rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">From the workspace</span>
25− )}
26− <span className="ml-auto shrink-0 text-xs text-faint">
27− Updated <TimeAgo at={setting.updatedAt} />
28− </span>
29− {manage && !inherited && (
30− <Form method="post" className="shrink-0">
31− <input type="hidden" name="intent" value="delete" />
32− <input type="hidden" name="kind" value={kind} />
33− <input type="hidden" name="name" value={setting.name} />
34− <button
35− type="submit"
36− disabled={busy}
37− aria-label={`Remove ${setting.name}`}
38− title="Remove"
39− className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
40− >
41− <Trash2 size={14} />
42− </button>
43− </Form>
44− )}
45− </li>
46− );
17+/** The environments every deployment knows; workflow jobs may name others. */
18+const KNOWN_ENVIRONMENTS = ["production", "preview"];
19+
20+const READERS: Record<string, string> = { workflows: "Workflows", deployments: "Deployments" };
21+
22+function environmentsLabel(environments: string[]): string {
23+ if (environments.length === 0) return "All environments";
24+ return environments.map((env) => env.charAt(0).toUpperCase() + env.slice(1)).join(", ");
4725 }
4826
49−function Section({
50− kind,
51− items,
27+const SELECT =
28+ "rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
29+
30+export function SecretsPanel({
31+ data,
32+ action,
5233 scope,
5334 manage,
54− action,
5535 }: {
56− kind: SettingKind;
57− items: Setting[];
36+ data: SecretsData;
37+ action: SecretsAction | undefined;
5838 scope: "repository" | "workspace";
5939 manage: boolean;
60− action: SecretsAction | undefined;
6140 }) {
62− const navigation = useNavigation();
63− const form = useRef<HTMLFormElement>(null);
64− const mine = action?.kind === kind;
65− // Clear the form once something was saved.
66− useEffect(() => {
67− if (mine && action?.done && navigation.state === "idle") form.current?.reset();
68− }, [mine, action, navigation.state]);
69− const secret = kind === "secret";
41+ const location = useLocation();
42+ const params = new URLSearchParams(location.search);
43+ const editing = params.get("edit");
44+ const adding = params.has("add");
45+ const row = editing ? data.rows.find((r) => r.id === editing && r.scope === scope) : undefined;
46+ const [query, setQuery] = useState("");
47+ const [type, setType] = useState("all");
48+ const [environment, setEnvironment] = useState("all");
49+ const environments = useMemo(
50+ () => [...new Set([...KNOWN_ENVIRONMENTS, ...data.rows.flatMap((r) => r.environments)])],
51+ [data.rows],
52+ );
53+ const shown = data.rows.filter(
54+ (r) =>
55+ (!query || r.name.toLowerCase().includes(query.toLowerCase()) || r.note?.toLowerCase().includes(query.toLowerCase())) &&
56+ (type === "all" || r.kind === type) &&
57+ (environment === "all" || r.environments.length === 0 || r.environments.includes(environment)),
58+ );
59+
7060 return (
71− <section>
72− <h3 className="flex items-center gap-2 text-sm font-medium">
73− {secret ? <KeyRound size={15} className="text-accent" /> : <Variable size={15} className="text-accent" />}
74− {secret ? "Secrets" : "Variables"}
75− </h3>
76− <p className="mt-1 max-w-2xl text-sm text-muted">
77− {secret ? (
78− <>
79− Read in workflows as <code className="text-fg">{"${{ secrets.NAME }}"}</code>. Values are sealed when
80− saved, never shown again, and hidden in logs.
81− </>
82− ) : (
83− <>
84− Read in workflows as <code className="text-fg">{"${{ vars.NAME }}"}</code>. For settings that are not
85− secret; their values are shown.
86− </>
61+ <div className="max-w-5xl">
62+ <header className="flex flex-wrap items-start justify-between gap-4">
63+ <div>
64+ <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2>
65+ <p className="mt-1 max-w-2xl text-sm text-muted">
66+ One list for everything that reads them. Each row says which environments it applies to and whether{" "}
67+ <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
68+ and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
69+ (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
70+ {scope === "workspace"
71+ ? " Every repository, or the ones you link, reads the workspace's; a repository's own row of the same key wins."
72+ : " Rows from the workspace are shown too; adding the same key here replaces them for this repository."}{" "}
73+ <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline">
74+ How they are read
75+ </a>
76+ </p>
77+ </div>
78+ {manage && (
79+ <ButtonLink to="?add" variant="accent">
80+ <Plus size={14} />
81+ Add
82+ </ButtonLink>
8783 )}
88− {scope === "workspace" && " Every repository in the workspace reads these, unless it has its own of the same name."}
84+ </header>
85+
86+ <p className="mt-4 rounded-lg border border-line bg-surface px-4 py-2.5 text-xs text-muted">
87+ Built in: workflows get <code className="text-fg">secrets.G1T_TOKEN</code>, the workspace's own token for
88+ the run, with <code className="text-fg">secrets.GITHUB_TOKEN</code> as its alias. Agents, acceptance checks
89+ and the merge queue never read secrets or variables, and runs for people outside the workspace get no secrets.
8990 </p>
91+
92+ <div className="mt-5 flex flex-wrap gap-2">
93+ <label className="relative min-w-56 grow">
94+ <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
95+ <input
96+ value={query}
97+ onChange={(e) => setQuery(e.target.value)}
98+ placeholder="Search keys and notes"
99+ aria-label="Search"
100+ className={`${SELECT} w-full pl-9`}
101+ />
102+ </label>
103+ <select value={type} onChange={(e) => setType(e.target.value)} aria-label="Type" className={SELECT}>
104+ <option value="all">All types</option>
105+ <option value="secret">Secret</option>
106+ <option value="variable">Config</option>
107+ </select>
108+ <select value={environment} onChange={(e) => setEnvironment(e.target.value)} aria-label="Environment" className={SELECT}>
109+ <option value="all">All environments</option>
110+ {environments.map((env) => (
111+ <option key={env} value={env}>
112+ {environmentsLabel([env])}
113+ </option>
114+ ))}
115+ </select>
116+ </div>
117+
118+ <ErrorText>{data.error}</ErrorText>
119+ {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
120+
90121 <div className="mt-4">
91− {items.length === 0 ? (
92− <EmptyState title={secret ? "No secrets yet" : "No variables yet"} />
122+ {data.rows.length === 0 ? (
123+ <EmptyState title="No secrets or variables yet">
124+ Add one, or paste a <code>.env</code> file into Add to bring many at once.
125+ </EmptyState>
126+ ) : shown.length === 0 ? (
127+ <EmptyState title="Nothing matches" />
93128 ) : (
94129 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
95− {items.map((item) => (
96− <SettingRow key={`${item.scope}:${item.name}`} setting={item} kind={kind} manage={manage} inherited={item.scope !== scope} />
130+ {shown.map((r) => (
131+ <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
97132 ))}
98133 </ul>
99134 )}
100135 </div>
101− {manage && (
102− <Form ref={form} method="post" className="mt-4 grid gap-3 rounded-xl border border-line bg-surface p-4">
103− <input type="hidden" name="intent" value="set" />
104− <input type="hidden" name="kind" value={kind} />
105− <Field label="Name" hint="Letters, digits and underscores. Saving one that exists replaces it.">
106− <Input name="name" required placeholder={secret ? "NPM_TOKEN" : "DEPLOY_REGION"} autoComplete="off" />
107− </Field>
108− <Field label="Value">
109− {secret ? (
110− <Textarea name="value" required rows={3} autoComplete="off" spellCheck={false} />
111− ) : (
112− <Input name="value" autoComplete="off" />
136+
137+ {manage && (adding || row) && (
138+ <Drawer row={row} scope={scope} repositories={data.repositories} error={action?.error} />
139+ )}
140+ </div>
141+ );
142+}
143+
144+function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
145+ const busy = useNavigation().state === "submitting";
146+ const secret = row.kind === "secret";
147+ return (
148+ <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]">
149+ <div className="min-w-0">
150+ <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
151+ {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
152+ {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
153+ </div>
154+ <span className="truncate text-muted">{environmentsLabel(row.environments)}</span>
155+ <span className="hidden truncate text-xs text-muted md:block">
156+ {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")}
157+ </span>
158+ <span className="hidden items-center gap-1.5 text-xs text-muted md:flex">
159+ {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />}
160+ {secret ? "Secret" : "Config"}
161+ </span>
162+ <span className="hidden text-xs text-faint md:block">
163+ <TimeAgo at={row.updatedAt} />
164+ </span>
165+ <span className="flex items-center justify-end gap-1">
166+ {inherited ? (
167+ <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
168+ ) : (
169+ <>
170+ {row.repositories.length > 0 && (
171+ <span className="mr-1 text-xs text-faint" title={row.repositories.join(", ")}>
172+ {row.repositories.length} {row.repositories.length === 1 ? "repository" : "repositories"}
173+ </span>
174+ )}
175+ {manage && (
176+ <>
177+ <Link
178+ to={`?edit=${row.id}`}
179+ aria-label={`Edit ${row.name}`}
180+ className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
181+ >
182+ <Pencil size={14} />
183+ </Link>
184+ <Form method="post">
185+ <input type="hidden" name="intent" value="delete" />
186+ <input type="hidden" name="id" value={row.id} />
187+ <input type="hidden" name="name" value={row.name} />
188+ <button
189+ type="submit"
190+ disabled={busy}
191+ aria-label={`Remove ${row.name}`}
192+ className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
193+ >
194+ <Trash2 size={14} />
195+ </button>
196+ </Form>
197+ </>
113198 )}
114− </Field>
115− <div className="flex items-center gap-3">
116− <Button type="submit" disabled={navigation.state === "submitting"}>
117− {secret ? "Save secret" : "Save variable"}
118− </Button>
119− {mine && action?.done && <span className="text-sm text-muted">{action.done}</span>}
120− </div>
121− {mine && <ErrorText>{action?.error}</ErrorText>}
122− </Form>
123− )}
124− </section>
199+ </>
200+ )}
201+ </span>
202+ </li>
125203 );
126204 }
127205
128−export function SecretsPanel({
129− data,
130− action,
206+function Drawer({
207+ row,
131208 scope,
132− manage,
209+ repositories,
210+ error,
133211 }: {
134− data: SecretsData;
135− action: SecretsAction | undefined;
212+ row: Setting | undefined;
136213 scope: "repository" | "workspace";
137− manage: boolean;
214+ repositories: string[];
215+ error: string | undefined;
138216 }) {
217+ const busy = useNavigation().state === "submitting";
218+ const editing = !!row;
219+ const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
220+ const [some, setSome] = useState(!!row && row.environments.length > 0);
221+ const [reach, setReach] = useState(row && row.repositories.length > 0 ? "some" : "all");
222+ const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
223+ const field =
224+ "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
139225 return (
140− <div className="max-w-4xl space-y-12">
141− <ErrorText>{data.error}</ErrorText>
142− <Section kind="secret" items={data.secrets} scope={scope} manage={manage} action={action} />
143− <Section kind="variable" items={data.variables} scope={scope} manage={manage} action={action} />
226+ <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
227+ <Link to="?" aria-label="Close" className="grow" />
228+ <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg shadow-2xl">
229+ <div className="flex items-center justify-between border-b border-line px-6 py-4">
230+ <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
231+ <Link to="?" aria-label="Close" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg">
232+ <X size={16} />
233+ </Link>
234+ </div>
235+ <div className="grow space-y-6 overflow-y-auto px-6 py-5">
236+ <input type="hidden" name="intent" value="save" />
237+ {row && <input type="hidden" name="id" value={row.id} />}
238+
239+ <fieldset>
240+ <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
241+ <div className="grid gap-3 sm:grid-cols-2">
242+ {(
243+ [
244+ ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens."],
245+ ["config", "Config", "Readable by members after saving. For values that are not sensitive."],
246+ ] as const
247+ ).map(([value, title, text]) => {
248+ // A secret's value is sealed: it can never become config.
249+ const locked = value === "config" && row?.kind === "secret";
250+ return (
251+ <label
252+ key={value}
253+ className={`rounded-xl border p-3.5 transition-colors ${
254+ type === value ? "border-accent bg-accent/5" : "border-line hover:border-line-strong"
255+ } ${locked ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
256+ >
257+ <span className="flex items-center justify-between">
258+ <span className="text-sm font-medium">{title}</span>
259+ <input
260+ type="radio"
261+ name="type"
262+ value={value}
263+ checked={type === value}
264+ disabled={locked}
265+ onChange={() => setType(value)}
266+ className="accent-accent"
267+ />
268+ </span>
269+ <span className="mt-1 block text-xs text-muted">{text}</span>
270+ </label>
271+ );
272+ })}
273+ </div>
274+ {row?.kind === "variable" && (
275+ <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
276+ )}
277+ </fieldset>
278+
279+ <label className="block">
280+ <span className="mb-1.5 block text-sm font-medium text-muted">Key</span>
281+ {editing ? (
282+ <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} />
283+ ) : (
284+ <textarea
285+ name="key"
286+ required
287+ rows={1}
288+ placeholder="CLIENT_KEY, or paste a .env file"
289+ autoComplete="off"
290+ spellCheck={false}
291+ className={`${field} min-h-10 font-mono`}
292+ />
293+ )}
294+ </label>
295+
296+ <label className="block">
297+ <span className="mb-1.5 block text-sm font-medium text-muted">Value</span>
298+ <textarea
299+ name="value"
300+ rows={type === "secret" ? 3 : 2}
301+ defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""}
302+ placeholder={
303+ editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value"
304+ }
305+ autoComplete="off"
306+ spellCheck={false}
307+ className={`${field} font-mono`}
308+ />
309+ </label>
310+
311+ <label className="block">
312+ <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
313+ <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
314+ </label>
315+
316+ <fieldset>
317+ <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
318+ <div className="space-y-2 text-sm">
319+ <label className="flex items-center gap-2">
320+ <input type="radio" name="scope" value="all" checked={!some} onChange={() => setSome(false)} className="accent-accent" />
321+ All environments
322+ </label>
323+ <label className="flex items-center gap-2">
324+ <input type="radio" name="scope" value="some" checked={some} onChange={() => setSome(true)} className="accent-accent" />
325+ Only some
326+ </label>
327+ {some && (
328+ <div className="ml-6 space-y-2">
329+ {KNOWN_ENVIRONMENTS.map((env) => (
330+ <label key={env} className="flex items-center gap-2">
331+ <input
332+ type="checkbox"
333+ name="env"
334+ value={env}
335+ defaultChecked={row?.environments.includes(env)}
336+ className="accent-accent"
337+ />
338+ {environmentsLabel([env])}
339+ </label>
340+ ))}
341+ <input
342+ name="envCustom"
343+ defaultValue={custom.join(", ")}
344+ placeholder="Others, comma-separated: staging, qa"
345+ className={field}
346+ />
347+ <p className="text-xs text-faint">
348+ Deployments are production and preview; a workflow job reads the row for its{" "}
349+ <code>environment:</code>, and rows for all environments otherwise.
350+ </p>
351+ </div>
352+ )}
353+ </div>
354+ </fieldset>
355+
356+ <fieldset>
357+ <legend className="mb-2 text-sm font-medium text-muted">Available to</legend>
358+ <div className="space-y-2 text-sm">
359+ {(
360+ [
361+ ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
362+ ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
363+ ] as const
364+ ).map(([value, title, text]) => (
365+ <label key={value} className="flex items-start gap-2">
366+ <input
367+ type="checkbox"
368+ name="availableTo"
369+ value={value}
370+ defaultChecked={row ? row.availableTo.includes(value) : true}
371+ className="mt-1 accent-accent"
372+ />
373+ <span>
374+ {title}
375+ <span className="block text-xs text-faint">{text}</span>
376+ </span>
377+ </label>
378+ ))}
379+ </div>
380+ </fieldset>
381+
382+ {scope === "workspace" && (
383+ <fieldset>
384+ <legend className="mb-2 text-sm font-medium text-muted">Repositories</legend>
385+ <div className="space-y-2 text-sm">
386+ <label className="flex items-center gap-2">
387+ <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} className="accent-accent" />
388+ Every repository
389+ </label>
390+ <label className="flex items-center gap-2">
391+ <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} className="accent-accent" />
392+ Only these
393+ </label>
394+ {reach === "some" && (
395+ <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto sm:grid-cols-2">
396+ {repositories.map((name) => (
397+ <label key={name} className="flex items-center gap-2 font-mono text-xs">
398+ <input
399+ type="checkbox"
400+ name="repo"
401+ value={name}
402+ defaultChecked={row?.repositories.includes(name)}
403+ className="accent-accent"
404+ />
405+ {name}
406+ </label>
407+ ))}
408+ </div>
409+ )}
410+ </div>
411+ </fieldset>
412+ )}
413+ <ErrorText>{error}</ErrorText>
414+ </div>
415+ <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
416+ <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
417+ <Button type="submit" disabled={busy}>
418+ Save
419+ </Button>
420+ </div>
421+ </Form>
144422 </div>
145423 );
146424 }
+17−10
1313 CreditCard,
1414 GitPullRequest,
1515 History,
16+ Fingerprint,
1617 KeyRound,
1718 Layers,
1819 LayoutDashboard,
290291 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
291292 Members
292293 </SidebarLink>
294+ <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
295+ Access tokens
296+ </SidebarLink>
293297 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
294− Billing
298+ Billing and plans
295299 </SidebarLink>
296300 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
297301 Integrations
298− </SidebarLink>
299− <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
300− Webhooks
301302 </SidebarLink>
302303 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
303304 Secrets and variables
304305 </SidebarLink>
305− <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
306− Access tokens
306+ <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
307+ Webhooks
307308 </SidebarLink>
308309 </SidebarGroup>
309310 </nav>
433434 Mission control
434435 </Link>
435436 <SidebarGroup title="Account">
436− {item("ssh-keys", <KeyRound size={15} />, "SSH keys")}
437− {item("tokens", <Lock size={15} />, "Access tokens")}
437+ {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
438+ {item("tokens", <KeyRound size={15} />, "Access tokens")}
438439 {item("applications", <Plug size={15} />, "Connected applications")}
439440 </SidebarGroup>
440441 </nav>
619620 people: "Members",
620621 tokens: "Access tokens",
621622 usage: "Usage",
622− billing: "Billing",
623+ billing: "Billing and plans",
623624 integrations: "Integrations",
624625 webhooks: "Webhooks",
625626 tree: "Code",
655656 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
656657 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
657658 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
658− else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
659+ else if (third && SECTIONS[third]) {
660+ trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
661+ // A settings page names which one: Settings / Secrets and variables.
662+ if (third === "settings" && fourth && SECTIONS[fourth]) {
663+ trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
664+ }
665+ }
659666 }
660667 return (
661668 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
+75−22
1−import type { Setting, SettingKind, SettingsOwner, User } from "@g1t/contracts";
1+import { redirect } from "react-router";
22
3−import { actions } from "./services.server";
3+import type { Setting, SettingKind, SettingReader, SettingsOwner, User } from "@g1t/contracts";
4+
5+import { actions, repos } from "./services.server";
46
57 export type SecretsData = {
6− secrets: Setting[];
7− variables: Setting[];
8+ rows: Setting[];
9+ /** For a workspace: its repositories, to link rows to. */
10+ repositories: string[];
811 error: string | null;
912 };
1013
11−/** A repository's or a workspace's secrets and variables. */
14+/** A repository's or a workspace's secrets and variables, as one list. */
1215 export async function loadSecrets(owner: SettingsOwner, actor: User): Promise<SecretsData> {
13− const [secrets, variables] = await Promise.all([
14− actions.settings(actor, owner, "secret"),
15− actions.settings(actor, owner, "variable"),
16+ const [rows, list] = await Promise.all([
17+ actions.settings(actor, owner, "all"),
18+ "workspace" in owner ? repos.list(actor, { namespace: owner.workspace }) : Promise.resolve(null),
1619 ]);
1720 return {
18− secrets: secrets.ok ? secrets.value : [],
19− variables: variables.ok ? variables.value : [],
20− error: !secrets.ok ? secrets.error.message : !variables.ok ? variables.error.message : null,
21+ rows: rows.ok ? rows.value : [],
22+ repositories: Array.isArray(list) ? list.filter((repo) => !repo.forkOf).map((repo) => repo.name).sort() : [],
23+ error: rows.ok ? null : rows.error.message,
2124 };
2225 }
2326
24−export type SecretsAction = { done?: string; error?: string; kind?: SettingKind };
27+export type SecretsAction = { error?: string };
28+
29+/** `KEY=value` lines, as a .env file has them; quotes around a value are dropped. */
30+function parseDotenv(text: string): [string, string][] {
31+ const pairs: [string, string][] = [];
32+ for (const raw of text.split(/\r?\n/)) {
33+ const line = raw.replace(/^\s*export\s+/, "").trim();
34+ if (!line || line.startsWith("#")) continue;
35+ const at = line.indexOf("=");
36+ if (at <= 0) continue;
37+ let value = line.slice(at + 1).trim();
38+ if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1);
39+ pairs.push([line.slice(0, at).trim(), value]);
40+ }
41+ return pairs;
42+}
2543
26−export async function actOnSecrets(owner: SettingsOwner, actor: User, form: FormData): Promise<SecretsAction> {
44+/**
45+ * Saves the side panel's form (one row, or many pasted as a .env file), or
46+ * removes a row, then returns to the list.
47+ */
48+export async function actOnSecrets(owner: SettingsOwner, actor: User, form: FormData, page: string): Promise<SecretsAction> {
2749 const intent = String(form.get("intent") ?? "");
28− const kind: SettingKind = form.get("kind") === "variable" ? "variable" : "secret";
29− const name = String(form.get("name") ?? "").trim();
30− const what = kind === "secret" ? "Secret" : "Variable";
50+ const id = String(form.get("id") ?? "") || undefined;
3151 if (intent === "delete") {
32− const removed = await actions.deleteSetting(actor, owner, kind, name);
33− return removed.ok ? { done: `${what} ${name} removed.`, kind } : { error: removed.error.message, kind };
52+ const removed = await actions.deleteSetting(actor, owner, "all", String(form.get("name") ?? ""), id);
53+ if (!removed.ok) return { error: removed.error.message };
54+ throw redirect(page);
55+ }
56+ const kind: SettingKind = form.get("type") === "config" ? "variable" : "secret";
57+ const environments =
58+ form.get("scope") === "some"
59+ ? [
60+ ...form.getAll("env").map(String),
61+ ...String(form.get("envCustom") ?? "")
62+ .split(",")
63+ .map((name) => name.trim())
64+ .filter(Boolean),
65+ ]
66+ : [];
67+ if (form.get("scope") === "some" && environments.length === 0) {
68+ return { error: "Choose at least one environment, or All environments." };
3469 }
70+ const availableTo = form.getAll("availableTo").map(String) as SettingReader[];
71+ if (availableTo.length === 0) return { error: "Choose who reads it: Workflows, Deployments, or both." };
72+ const repositories =
73+ "workspace" in owner && form.get("reach") === "some" ? form.getAll("repo").map(String) : [];
74+ const note = String(form.get("note") ?? "");
75+ const key = String(form.get("key") ?? "").trim();
3576 const value = String(form.get("value") ?? "");
36− if (!name) return { error: "Give it a name.", kind };
37− if (kind === "secret" && !value) return { error: "Give the secret a value.", kind };
38− const saved = await actions.setSetting(actor, owner, kind, name, value);
39− return saved.ok ? { done: `${what} ${saved.value.name} saved.`, kind } : { error: saved.error.message, kind };
77+ // A pasted .env file adds a row for each line.
78+ const pasted = !id && key.includes("=") ? parseDotenv(key) : [];
79+ const entries: [string, string | null][] = pasted.length > 0 ? pasted : [[key, value === "" && id ? null : value]];
80+ for (const [name, entryValue] of entries) {
81+ if (!name) return { error: "Give it a key." };
82+ if (entryValue === "" && !id) return { error: `Give ${name} a value.` };
83+ const saved = await actions.setSetting(actor, owner, kind, name, entryValue, {
84+ id,
85+ availableTo,
86+ environments,
87+ repositories: "workspace" in owner ? repositories : undefined,
88+ note,
89+ });
90+ if (!saved.ok) return { error: pasted.length > 0 ? `${name}: ${saved.error.message}` : saved.error.message };
91+ }
92+ throw redirect(page);
4093 }
+1−0
5353 route("settings", "routes/repo/settings.tsx"),
5454 route("settings/webhooks", "routes/repo/webhooks.tsx"),
5555 route("settings/secrets", "routes/repo/secrets.tsx"),
56+ route("settings/deployments", "routes/repo/settings-deployments.tsx"),
5657 ]),
5758 // Anything else: a 404 that still knows who is signed in.
5859 route("*", "routes/not-found.tsx"),
+13−84
55 import type { Deployment, DeployStatus, FeatureState } from "@g1t/contracts";
66
77 import type { Route } from "./+types/deployments";
8−import { Button, ButtonLink, EmptyState, ErrorText, Field, Input, Textarea, TimeAgo } from "../../components/ui";
8+import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
99 import { billing, deployments } from "../../lib/services.server";
1010 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1111
2828 return { role, settings: unwrap(settings), ...unwrap(list), plan };
2929 }
3030
31−/** `KEY=value` lines as an object; blank lines and `#` comments are skipped. */
32−function parseEnv(text: string): Record<string, string> {
33− const vars: Record<string, string> = {};
34− for (const line of text.split(/\r?\n/)) {
35− const trimmed = line.trim();
36− if (!trimmed || trimmed.startsWith("#")) continue;
37− const at = trimmed.indexOf("=");
38− if (at > 0) vars[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1).trim();
39− }
40− return vars;
41−}
42−
4331 export async function action({ request, params, context }: Route.ActionArgs) {
4432 assertSameOrigin(request);
4533 const user = requireUser(context, request);
6149 ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
6250 : { error: saved.error.message };
6351 }
64− const on = (name: string) => form.get(name) === "on";
65− const saved = await deployments.updateSettings(user, path, {
66− previews: on("previews"),
67− production: on("production"),
68− buildCommand: String(form.get("buildCommand") ?? ""),
69− outputDir: String(form.get("outputDir") ?? ""),
70− buildEnv: parseEnv(String(form.get("buildEnv") ?? "")),
71− idleDays: Number(form.get("idleDays")),
72− });
73− return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
52+ return { error: "Unknown request." };
7453 }
7554
7655 const STATUS: Record<DeployStatus, { label: string; tone: string }> = {
201180 )}
202181 </div>
203182
204− <SettingsForm settings={settings} busy={busy} />
205−
206− <section className="mt-10 rounded-xl border border-danger/30 p-5">
207− <h2 className="text-sm font-medium">Turn off deployments</h2>
208− <p className="mt-1 text-sm text-muted">
209− Takes production and every preview down now, and stops building. Nothing of this repository's keeps
210− running or costing anything. The workspace's plan stays on; turn it off under Billing.
211− </p>
212− <Form method="post" className="mt-3">
213− <Button variant="quiet" type="submit" name="intent" value="disable" disabled={busy}>
214− Turn off deployments
215− </Button>
216− </Form>
217− </section>
183+ <p className="mt-10 text-sm text-muted">
184+ Build command, output directory, idle days, and turning deployments off are under{" "}
185+ <Link to={`${base}/settings/deployments`} className="text-fg hover:underline">
186+ Settings → Deployments
187+ </Link>
188+ . Secrets and config for builds and running apps are under{" "}
189+ <Link to={`${base}/settings/secrets`} className="text-fg hover:underline">
190+ Settings → Secrets and variables
191+ </Link>
192+ .
193+ </p>
218194 </>
219195 )}
220196 </div>
323299 </span>
324300 </Link>
325301 </li>
326− );
327−}
328−
329−function SettingsForm({ settings, busy }: { settings: Route.ComponentProps["loaderData"]["settings"]; busy: boolean }) {
330− const env = Object.entries(settings.buildEnv)
331− .map(([name, value]) => `${name}=${value}`)
332− .join("\n");
333− return (
334− <Form method="post" className="mt-10 space-y-5">
335− <h2 className="text-sm font-medium text-muted">Settings</h2>
336− <div className="grid gap-3 md:grid-cols-2">
337− <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
338− <input type="checkbox" name="production" defaultChecked={settings.production} className="mt-1 accent-accent" />
339− <span>
340− <span className="block text-sm font-medium">Production</span>
341− <span className="mt-1 block text-sm text-muted">Deploy the default branch on every push.</span>
342− </span>
343− </label>
344− <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
345− <input type="checkbox" name="previews" defaultChecked={settings.previews} className="mt-1 accent-accent" />
346− <span>
347− <span className="block text-sm font-medium">Previews</span>
348− <span className="mt-1 block text-sm text-muted">A preview for every open pull request, linked on it.</span>
349− </span>
350− </label>
351− </div>
352− <div className="grid gap-4 md:grid-cols-3">
353− <Field label="Build command" hint="Instead of the project's own build script.">
354− <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" />
355− </Field>
356− <Field label="Output directory" hint="For a static site; found by itself when empty.">
357− <Input name="outputDir" defaultValue={settings.outputDir ?? ""} placeholder="dist" />
358− </Field>
359− <Field label="Idle days" hint="A preview no one visits for this long comes down.">
360− <Input name="idleDays" type="number" min={1} max={90} defaultValue={settings.idleDays} />
361− </Field>
362− </div>
363− <Field
364− label="Build variables"
365− hint="KEY=value, one per line. The build runs with them; they are not secret, so keep keys in Secrets."
366− >
367− <Textarea name="buildEnv" rows={4} defaultValue={env} className="font-mono" placeholder="NODE_ENV=production" />
368− </Field>
369− <Button type="submit" disabled={busy}>
370− Save settings
371− </Button>
372− </Form>
373302 );
374303 }
+2−1
1919 export async function action({ request, params, context }: Route.ActionArgs) {
2020 assertSameOrigin(request);
2121 const user = requireUser(context, request);
22− return actOnSecrets(ownerOf(params), user, await request.formData());
22+ const page = `/${params.owner}/${params.repo}/settings/secrets`;
23+ return actOnSecrets(ownerOf(params), user, await request.formData(), page);
2324 }
2425
2526 export default function RepoSecrets({ loaderData, actionData, params }: Route.ComponentProps) {
+131−0
1+import { Form, Link, data, useNavigation } from "react-router";
2+
3+import type { Route } from "./+types/settings-deployments";
4+import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
5+import { Button, ErrorText, Field, Input } from "../../components/ui";
6+import { deployments } from "../../lib/services.server";
7+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
8+
9+export function meta({ params }: Route.MetaArgs) {
10+ return [{ title: `Deployment settings · ${params.owner}/${params.repo} · g1t` }];
11+}
12+
13+export async function loader({ params, context }: Route.LoaderArgs) {
14+ const viewer = getViewer(context);
15+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
16+ const settings = await deployments.settings({ namespace: params.owner, name: params.repo }, viewer);
17+ return { settings: unwrap(settings) };
18+}
19+
20+export async function action({ request, params, context }: Route.ActionArgs) {
21+ assertSameOrigin(request);
22+ const user = requireUser(context, request);
23+ const form = await request.formData();
24+ const path = { namespace: params.owner, name: params.repo };
25+ const intent = form.get("intent");
26+ if (intent === "enable" || intent === "disable") {
27+ const saved = await deployments.updateSettings(user, path, { enabled: intent === "enable" });
28+ return saved.ok
29+ ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
30+ : { error: saved.error.message };
31+ }
32+ const on = (name: string) => form.get(name) === "on";
33+ const saved = await deployments.updateSettings(user, path, {
34+ previews: on("previews"),
35+ production: on("production"),
36+ buildCommand: String(form.get("buildCommand") ?? ""),
37+ outputDir: String(form.get("outputDir") ?? ""),
38+ idleDays: Number(form.get("idleDays")),
39+ });
40+ return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
41+}
42+
43+function Check({ name, on, title, children }: { name: string; on: boolean; title: string; children: string }) {
44+ return (
45+ <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
46+ <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" />
47+ <span>
48+ <span className="block text-sm font-medium">{title}</span>
49+ <span className="mt-1 block text-sm text-muted">{children}</span>
50+ </span>
51+ </label>
52+ );
53+}
54+
55+export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
56+ const { settings } = loaderData;
57+ const busy = useNavigation().state === "submitting";
58+ const base = `/${params.owner}/${params.repo}`;
59+ return (
60+ <div className="max-w-4xl">
61+ <RepoSettingsTabs base={base} />
62+ <div className="min-h-6">
63+ {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>}
64+ <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
65+ </div>
66+
67+ {!settings.enabled ? (
68+ <section className="rounded-xl border border-line bg-surface p-5">
69+ <h2 className="font-medium">Deployments are off</h2>
70+ <p className="mt-1 text-sm text-muted">
71+ Turn them on to build production at{" "}
72+ <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span> and a preview for
73+ every pull request. The workspace needs the Deployments plan, under Billing.
74+ </p>
75+ <Form method="post" className="mt-4">
76+ <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}>
77+ Turn on deployments
78+ </Button>
79+ </Form>
80+ </section>
81+ ) : (
82+ <>
83+ <Form method="post" className="space-y-5">
84+ <div className="grid gap-3 md:grid-cols-2">
85+ <Check name="production" on={settings.production} title="Production">
86+ Deploy the default branch on every push.
87+ </Check>
88+ <Check name="previews" on={settings.previews} title="Previews">
89+ A preview for every open pull request, linked on it.
90+ </Check>
91+ </div>
92+ <div className="grid gap-4 md:grid-cols-3">
93+ <Field label="Build command" hint="Instead of the project's own build script.">
94+ <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" />
95+ </Field>
96+ <Field label="Output directory" hint="For a static site; found by itself when empty.">
97+ <Input name="outputDir" defaultValue={settings.outputDir ?? ""} placeholder="dist" />
98+ </Field>
99+ <Field label="Idle days" hint="A preview no one visits for this long comes down.">
100+ <Input name="idleDays" type="number" min={1} max={90} defaultValue={settings.idleDays} />
101+ </Field>
102+ </div>
103+ <p className="text-sm text-muted">
104+ Builds and running apps read the{" "}
105+ <Link to={`${base}/settings/secrets`} className="text-fg hover:underline">
106+ secrets and variables
107+ </Link>{" "}
108+ available to Deployments: each row for Production or Preview, or for all environments.
109+ </p>
110+ <Button type="submit" disabled={busy}>
111+ Save
112+ </Button>
113+ </Form>
114+
115+ <section className="mt-10 rounded-xl border border-danger/30 p-5">
116+ <h2 className="text-sm font-medium">Turn off deployments</h2>
117+ <p className="mt-1 text-sm text-muted">
118+ Takes production and every preview down now, and stops building. Nothing of this repository's keeps
119+ running or costing anything. The workspace's plan stays on; turn it off under Billing.
120+ </p>
121+ <Form method="post" className="mt-3">
122+ <Button variant="quiet" type="submit" name="intent" value="disable" disabled={busy}>
123+ Turn off deployments
124+ </Button>
125+ </Form>
126+ </section>
127+ </>
128+ )}
129+ </div>
130+ );
131+}
+3−3
2828 about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.",
2929 },
3030 usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." },
31− billing: { title: "Billing", about: "Agent credit, and every charge against it." },
31+ billing: { title: "Billing and plans", about: "Paid plans, agent credit, and every charge against it." },
3232 webhooks: {
3333 title: "Webhooks",
3434 about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.",
3535 },
3636 secrets: {
3737 title: "Secrets and variables",
38− about: "What every repository's GitHub Actions workflows read as secrets and vars. A repository's own, under its settings, replace these by name.",
38+ about: "Shared with every repository, or the ones you link: read by workflows, deployments, or both. A repository's own row of the same key wins.",
3939 },
4040 integrations: {
4141 title: "Integrations",
101101 icon={<Users size={15} />}
102102 count={workspace.memberCount}
103103 >
104− People
104+ Members
105105 </TabLink>
106106 <TabLink to={`${base}/-/tokens`} icon={<KeyRound size={15} />}>
107107 Access tokens
+2−1
1616 export async function action({ request, params, context }: Route.ActionArgs) {
1717 assertSameOrigin(request);
1818 const user = requireUser(context, request);
19− return actOnSecrets({ workspace: params.owner.toLowerCase() }, user, await request.formData());
19+ const page = `/${params.owner}/-/secrets`;
20+ return actOnSecrets({ workspace: params.owner.toLowerCase() }, user, await request.formData(), page);
2021 }
2122
2223 export default function WorkspaceSecrets({ loaderData, actionData }: Route.ComponentProps) {
+14−2
241241 Runs, jobs and logs are at GitHub's own routes under
242242 `{repo}/actions/...`. A run on a pull request's head is a check: pending
243243 holds the merge, failure refuses it and sends a g1t agent back to fix it.
244−Secrets and variables: `{repo}/actions/secrets`, `{repo}/actions/variables`.
244+Secrets and variables are one list per repository (site:
245+`g1t.sh/<owner>/<repo>/settings/secrets`) and per workspace: each row is a
246+key, Secret or Config, the environments it applies to (all, or e.g.
247+production/preview, or a job's `environment:`), and whether workflows,
248+deployments or both read it. API: `{repo}/actions/secrets` and
249+`{repo}/actions/variables` (GitHub's routes) with extra `environments`,
250+`availableTo`, `repositories`, `note`, `id`. Trusted jobs get
251+`secrets.G1T_TOKEN` (the workspace's token; `GITHUB_TOKEN` is its alias),
252+which cannot change secrets. Guide:
253+https://docs.g1t.sh/guides/secrets-and-variables/
245254
246255 ## Deployments
247256
249258 workspace's Billing ($5 a month: 10 apps, 1M requests, 3M CPU ms; builds
250259 and usage past that from credit at cost + 20%; never free). Then a member
251260 turns deployments on from the repository's Deployments page
252−(`g1t.sh/<owner>/<repo>/deployments`). Every pull request gets a preview at
261+(`g1t.sh/<owner>/<repo>/deployments`; settings under
262+`…/settings/deployments`). Builds and running apps read the secrets and
263+variables available to Deployments, each key's Production or Preview row.
264+Every pull request gets a preview at
253265 `https://pr-<n>--<repo>--<owner>.g1t.page`, shown on it as the check
254266 `g1t / deploy`; the default branch deploys to
255267 `https://<repo>--<owner>.g1t.page` on each push. Workers projects
+1−1
402402 note(Severity::Warning, Some(id), "`container`: steps run on g1t's runner image instead of that container.".to_owned());
403403 }
404404 if spec.contains_key("environment") {
405− note(Severity::Info, Some(id), "`environment`: protection rules are not enforced on g1t yet; the job runs with the repository's secrets.".to_owned());
405+ note(Severity::Info, Some(id), "`environment`: the job gets the values its secrets and variables give this environment; protection rules (approvals, wait timers, branch limits) are not enforced on g1t yet.".to_owned());
406406 }
407407 let (matrix, fail_fast, max_parallel) = match spec.get("strategy") {
408408 Some(Value::Object(strategy)) => (
+77−3
155155 pub done: bool,
156156 }
157157
158−/// A secret's or variable's name, and for a variable its value.
158+/// Who may read a secret or variable: workflows (`secrets.*` and `vars.*`
159+/// in GitHub Actions) and deployments (a deploy build's environment and the
160+/// running app's bindings). Agents, checks and the merge queue read none.
161+pub const CONSUMERS: [&str; 2] = ["workflows", "deployments"];
162+
163+/// One row of a repository's or workspace's secrets and variables, as
164+/// Vercel lists environment variables: a key, its type, the environments
165+/// it applies to and who reads it. A key may have one row per environment.
166+/// Secrets' values are never returned.
159167 #[derive(Clone, Debug, Serialize, Deserialize)]
160168 #[serde(rename_all = "camelCase")]
161169 pub struct Setting {
170+ #[serde(default)]
171+ pub id: String,
162172 pub name: String,
163− /// Variables only; secrets are never returned.
173+ /// `secret`, or `variable` (shown as Config).
174+ #[serde(default)]
175+ pub kind: String,
176+ /// A variable's value; secrets' are never returned.
164177 pub value: Option<String>,
165178 /// `repository` or `workspace`.
166179 pub scope: String,
167180 pub updated_at: String,
181+ /// `workflows` and/or `deployments`.
182+ #[serde(default)]
183+ pub available_to: Vec<String>,
184+ /// The environments it applies to; empty is every environment.
185+ #[serde(default)]
186+ pub environments: Vec<String>,
187+ /// A workspace's row: the repositories it reaches, by name; empty is
188+ /// every repository.
189+ #[serde(default)]
190+ pub repositories: Vec<String>,
191+ #[serde(default)]
192+ pub note: Option<String>,
193+ #[serde(default)]
194+ pub updated_by: Option<String>,
168195 }
169196
170197 // --- Methods ---------------------------------------------------------------
278305 pub actor: User,
279306 #[serde(flatten)]
280307 pub owner: SettingsOwner,
308+ /// `secret` or `variable`. Changing a variable's row to `secret` seals
309+ /// it; a secret cannot become a variable.
281310 pub kind: String,
282311 pub name: String,
283− pub value: String,
312+ /// The row to change. Left out, the key's row for every environment, as
313+ /// GitHub's API addresses a secret by name alone.
314+ #[serde(default)]
315+ pub id: Option<String>,
316+ /// Needed for a new row; left out, an existing row keeps its value.
317+ #[serde(default)]
318+ pub value: Option<String>,
319+ /// `workflows` and/or `deployments`; left out, unchanged (both, for a
320+ /// new row).
321+ #[serde(default, alias = "availableTo")]
322+ pub available_to: Option<Vec<String>>,
323+ /// The environments it applies to; empty is every one. Left out,
324+ /// unchanged.
325+ #[serde(default)]
326+ pub environments: Option<Vec<String>>,
327+ /// A workspace's row: repository names; empty for every one.
328+ #[serde(default)]
329+ pub repositories: Option<Vec<String>>,
330+ #[serde(default)]
331+ pub note: Option<String>,
332+}
333+
334+/// `resolve_settings`: the secrets and variables one reader gets, for the
335+/// services that hand them out (the deployments service). Returns
336+/// `ResolvedSettings`.
337+#[derive(Debug, Serialize, Deserialize)]
338+#[serde(rename_all = "camelCase")]
339+pub struct ResolveSettingsArgs {
340+ pub repo_id: String,
341+ pub repo: RepoPath,
342+ /// `workflows` or `deployments`.
343+ pub consumer: String,
344+ /// The environment being read for, such as `production` or `preview`.
345+ #[serde(default)]
346+ pub environment: Option<String>,
347+ /// Whether the run is trusted; an untrusted one gets no secrets.
348+ pub trusted: bool,
349+}
350+
351+#[derive(Debug, Default, Serialize, Deserialize)]
352+pub struct ResolvedSettings {
353+ pub secrets: serde_json::Map<String, serde_json::Value>,
354+ pub variables: serde_json::Map<String, serde_json::Value>,
284355 }
285356
286357 /// `delete_setting`. Returns `Outcome<bool>`.
291362 pub owner: SettingsOwner,
292363 pub kind: String,
293364 pub name: String,
365+ /// One row; left out, every row of the key.
366+ #[serde(default)]
367+ pub id: Option<String>,
294368 }
295369
296370 /// `job_spec` and `job_report`: the sandbox running a job, with the job's
+18−10
2323 //! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report.
2424 //! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out.
2525 //! - `BUILD_COMMAND`, `OUTPUT_DIR`: the repository's own choices, if any.
26−//! - `BUILD_ENV`: a JSON object of variables the build runs with.
26+//! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's
27+//! variables and secrets for deploy builds. Both are set for the build;
28+//! secrets' values are redacted from its log.
2729
2830 use std::collections::BTreeMap;
2931 use std::path::{Path, PathBuf};
536538
537539 fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
538540 check_out(secrets).context("the commit could not be checked out")?;
539− // What the repository's settings ask the build to run with.
540− if let Ok(vars) = std::env::var("BUILD_ENV")
541− && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
542− {
543− for (name, value) in vars {
544− if let Some(value) = value.as_str() {
545− // SAFETY: single-threaded; set before any command runs.
546− unsafe { std::env::set_var(name, value) };
541+ // The repository's variables and secrets for deploy builds.
542+ for source in ["BUILD_ENV", "BUILD_SECRETS"] {
543+ if let Ok(vars) = std::env::var(source)
544+ && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
545+ {
546+ for (name, value) in vars {
547+ if let Some(value) = value.as_str() {
548+ // SAFETY: single-threaded; set before any command runs.
549+ unsafe { std::env::set_var(name, value) };
550+ }
547551 }
548552 }
549553 }
598602 return 2;
599603 }
600604 };
601− let secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
605+ let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
602606 .iter()
603607 .filter_map(|name| std::env::var(name).ok())
604608 .filter(|secret| !secret.is_empty())
605609 .collect();
610+ // The repository's build secrets never appear in the log.
611+ if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) {
612+ secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned));
613+ }
606614 if let Err(error) = reporter.send("started", json!({})) {
607615 eprintln!("g1t-runner: {error:#}");
608616 return 1;
+49−4
9696 export type LogChunk = { seq: number; step: number; text: string };
9797 export type JobLog = { chunks: LogChunk[]; done: boolean };
9898
99+/**
100+ * Who may read a secret or variable: `workflows` (`secrets.*`, `vars.*` in
101+ * GitHub Actions) and `deployments` (a deploy build's environment and the
102+ * running app's bindings). Agents, checks and the merge queue never read
103+ * any.
104+ */
105+export type SettingReader = "workflows" | "deployments";
106+
107+/**
108+ * One row of secrets and variables, as Vercel lists environment variables:
109+ * a key, its type, the environments it applies to and who reads it. A key
110+ * may have one row per environment. Secrets' values are never returned.
111+ */
99112 export type Setting = {
113+ id: string;
100114 name: string;
101− /** Variables only. */
115+ /** `variable` is shown as Config. Config may become a secret, never back. */
116+ kind: SettingKind;
117+ /** A variable's value. */
102118 value: string | null;
103119 scope: "repository" | "workspace";
104120 updatedAt: string;
121+ availableTo: SettingReader[];
122+ /** The environments it applies to; empty is every environment. */
123+ environments: string[];
124+ /** A workspace's row: the repositories it reaches; empty is every one. */
125+ repositories: string[];
126+ /** Where to rotate it, or who to ask. */
127+ note: string | null;
128+ updatedBy: string | null;
105129 };
106130
131+/** What saving a row sets beyond its value; left out is unchanged. */
132+export type SettingOptions = {
133+ /** The row to change; left out, the key's row for every environment. */
134+ id?: string;
135+ availableTo?: SettingReader[];
136+ environments?: string[];
137+ repositories?: string[];
138+ note?: string;
139+};
140+
107141 export type SettingsOwner = { repo: RepoPath } | { workspace: string };
108142 export type SettingKind = "secret" | "variable";
143+/** `all` lists both. */
144+export type SettingKindFilter = SettingKind | "all";
109145
110146 export type RunsFilter = {
111147 workflow?: string;
131167 cancel(actor: User, repo: RepoPath, id: string): Promise<Result<WorkflowRun>>;
132168 rerun(actor: User, repo: RepoPath, id: string, failedOnly?: boolean): Promise<Result<WorkflowRun>>;
133169 setWorkflowEnabled(actor: User, repo: RepoPath, workflow: string, enabled: boolean): Promise<Result<Workflow>>;
134− settings(actor: User, owner: SettingsOwner, kind: SettingKind): Promise<Result<Setting[]>>;
135− setSetting(actor: User, owner: SettingsOwner, kind: SettingKind, name: string, value: string): Promise<Result<Setting>>;
136− deleteSetting(actor: User, owner: SettingsOwner, kind: SettingKind, name: string): Promise<Result<boolean>>;
170+ settings(actor: User, owner: SettingsOwner, kind: SettingKindFilter): Promise<Result<Setting[]>>;
171+ /** `value` null keeps an existing entry's default value. */
172+ setSetting(
173+ actor: User,
174+ owner: SettingsOwner,
175+ kind: SettingKind,
176+ name: string,
177+ value: string | null,
178+ options?: SettingOptions,
179+ ): Promise<Result<Setting>>;
180+ /** One row by `id`, or every row of the key. */
181+ deleteSetting(actor: User, owner: SettingsOwner, kind: SettingKindFilter, name: string, id?: string): Promise<Result<boolean>>;
137182 }
+3−2
256256 setWorkflowEnabled: (actor, repo, workflow, enabled) =>
257257 call("set_workflow_enabled", { actor, repo, workflow, enabled }),
258258 settings: (actor, owner, kind) => call("settings", { actor, ...owner, kind }),
259− setSetting: (actor, owner, kind, name, value) => call("set_setting", { actor, ...owner, kind, name, value }),
260− deleteSetting: (actor, owner, kind, name) => call("delete_setting", { actor, ...owner, kind, name }),
259+ setSetting: (actor, owner, kind, name, value, options = {}) =>
260+ call("set_setting", { actor, ...owner, kind, name, value, ...options }),
261+ deleteSetting: (actor, owner, kind, name, id) => call("delete_setting", { actor, ...owner, kind, name, id }),
261262 };
262263 }
263264
+0−2
2525 buildCommand: string | null;
2626 /** What to serve, for a static site; found by itself when null. */
2727 outputDir: string | null;
28− /** Variables the build runs with. Not secret: shown to members. */
29− buildEnv: Record<string, string>;
3028 /** A preview no one has visited in this many days is taken down. */
3129 idleDays: number;
3230 /** Where production is served. */
+38−0
1+-- Secrets and variables become one list, as Vercel's environment variables
2+-- are: each row is a key, its type (secret or config), the environments it
3+-- applies to and who reads it. A key may have one row per environment, so
4+-- the unique (owner, kind, name) constraint goes; the service keeps a
5+-- key's rows from overlapping. Existing rows keep working as before: every
6+-- environment, read by workflows and deployments.
7+
8+CREATE TABLE settings_v2 (
9+ id TEXT PRIMARY KEY,
10+ -- repository or workspace.
11+ scope TEXT NOT NULL,
12+ -- The repository's id, or the workspace's slug.
13+ owner TEXT NOT NULL,
14+ -- secret or variable (shown as Config). A variable may become a secret;
15+ -- a secret never becomes a variable.
16+ kind TEXT NOT NULL,
17+ name TEXT NOT NULL,
18+ -- A secret's is sealed, bound to the row's id.
19+ value TEXT NOT NULL,
20+ updated_at TEXT NOT NULL,
21+ -- workflows and deployments, comma-separated.
22+ available_to TEXT NOT NULL DEFAULT 'workflows,deployments',
23+ -- The environments it applies to, comma-separated (production, preview,
24+ -- or a workflow job's `environment:`). Empty is every environment.
25+ environments TEXT NOT NULL DEFAULT '',
26+ -- A workspace's row: the repositories it reaches, as a JSON array of
27+ -- names. Null is every repository.
28+ repositories TEXT,
29+ -- Where to rotate it, or who to ask.
30+ note TEXT,
31+ updated_by TEXT
32+);
33+
34+INSERT INTO settings_v2 (id, scope, owner, kind, name, value, updated_at)
35+ SELECT id, scope, owner, kind, name, value, updated_at FROM settings;
36+DROP TABLE settings;
37+ALTER TABLE settings_v2 RENAME TO settings;
38+CREATE INDEX settings_by_owner ON settings (owner, name);
+1−0
178178 "settings" => reply(&service.settings(args(body)?).await?),
179179 "set_setting" => reply(&service.set_setting(args(body)?).await?),
180180 "delete_setting" => reply(&service.delete_setting(args(body)?).await?),
181+ "resolve_settings" => reply(&service.resolve_settings(args(body)?).await?),
181182 "job_spec" => reply(&service.job_spec(args(body)?).await?),
182183 "job_auth" => reply(&service.job_auth(args(body)?).await?),
183184 "job_report" => reply(&service.job_report(args(body)?).await?),
+24−6
237237 info.workflow_path = new.path.clone();
238238 info.run_id = id.clone();
239239 info.run_number = u64::from(numbered);
240− let vars = self.variables_for(&new.repo.id, &new.repo.namespace).await?;
240+ let vars = self
241+ .variables_for(&new.repo.id, &format!("{}/{}", new.repo.namespace, new.repo.name), None, new.trusted)
242+ .await?;
241243
242244 // run-name and the concurrency group read github, inputs and vars.
243245 let mut contexts = Map::new();
474476 /// Decides on one job whose needs are done: skip it, fail it, or expand
475477 /// it into its matrix and queue it.
476478 async fn decide(&self, run: &RunRow, job: &workflow::Job, row: &JobRow, needed: &[(&String, Vec<&JobRow>)]) -> Result<()> {
477− let vars = self.variables_for(&run.repo_id, &repo_path(&run.repo).namespace).await?;
479+ let vars = self.variables_for(&run.repo_id, &run.repo, None, run.trusted != 0).await?;
478480 let mut contexts = Self::base_contexts(run, &vars, &job.id);
479481 // A called workflow's jobs read the inputs they were called with.
480482 let call = row.call();
12321234 let spec = &spec;
12331235 let repo = repo_path(&run.repo);
12341236 let trusted = run.trusted != 0;
1235− // GITHUB_TOKEN: the workspace's, for as long as the job may run.
1237+ // The job's `environment:`, by name: entries with a value for it give
1238+ // that value instead of their default, as GitHub's environment
1239+ // secrets do.
1240+ let environment: Option<String> = match spec.raw.get("environment") {
1241+ Some(Value::String(name)) if !name.contains("${{") => Some(name.clone()),
1242+ Some(Value::Object(env)) => env.get("name").and_then(Value::as_str).filter(|n| !n.contains("${{")).map(str::to_owned),
1243+ _ => None,
1244+ };
1245+ // G1T_TOKEN, and GITHUB_TOKEN as its alias: the workspace's own
1246+ // token, for as long as the job may run.
12361247 let token = if trusted {
12371248 match self.workspace_actor(&repo.namespace).await? {
12381249 Some(workspace) => {
12411252 "create_access_token",
12421253 &CreateAccessTokenArgs {
12431254 user: workspace,
1244− name: format!("GITHUB_TOKEN for {} run {}", run.repo, run.number),
1255+ name: format!("G1T_TOKEN for {} run {}", run.repo, run.number),
12451256 ttl_seconds: Some(u64::from(job.timeout_minutes) * 60 + 600),
12461257 },
12471258 )
12531264 } else {
12541265 String::new()
12551266 };
1256− let mut secrets = if trusted { self.secrets_for(&run.repo_id, &repo.namespace).await? } else { Map::new() };
1267+ // A run that is not trusted (a pull request from outside the
1268+ // workspace) gets no secrets and an empty token.
1269+ let mut secrets = if trusted {
1270+ self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?
1271+ } else {
1272+ Map::new()
1273+ };
1274+ secrets.insert("G1T_TOKEN".into(), Value::String(token.clone()));
12571275 secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone()));
12581276 let masks: Vec<String> = secrets.values().filter_map(|v| v.as_str()).filter(|v| v.len() >= 4).map(str::to_owned).collect();
1259− let vars = self.variables_for(&run.repo_id, &repo.namespace).await?;
1277+ let vars = self.variables_for(&run.repo_id, &run.repo, environment.as_deref(), trusted).await?;
12601278
12611279 let jobs = self.job_rows(&run.id).await?;
12621280 let mut needs = Map::new();
+365−96
1−//! Secrets and variables, a repository's or its workspace's. A
2−//! repository's override its workspace's of the same name. Names are
3−//! upper-cased, as GitHub treats them without regard to case.
1+//! Secrets and variables, a repository's or its workspace's: one list for
2+//! every reader, shaped like Vercel's environment variables. Each row is a
3+//! key, its type (a secret, or a variable shown as Config), the
4+//! environments it applies to and who reads it: workflows, deployments, or
5+//! both. A key may have one row per environment, so production and
6+//! previews can hold different values; a key's rows never overlap.
7+//!
8+//! A reader asking for an environment gets the row naming it, else the
9+//! key's row for every environment. A repository's row overrides its
10+//! workspace's of the same key. Names are upper-cased, as GitHub treats
11+//! them without regard to case. Agents never read any.
412
5−use g1t_contracts::actions::{DeleteSettingArgs, SetSettingArgs, Setting, SettingsArgs, SettingsOwner};
13+use g1t_contracts::actions::{
14+ CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs,
15+ SettingsOwner,
16+};
617 use g1t_contracts::time::rfc3339;
718 use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
819 use g1t_kit::now_ms;
920 use serde::Deserialize;
1021 use serde_json::{Map, Value};
1122 use worker::Result;
23+use worker::wasm_bindgen::JsValue;
1224
1325 use crate::{Actions, check, fail};
1426
1527 /// The largest value, as on GitHub.
1628 const MAX_VALUE_BYTES: usize = 48 * 1024;
17−const MAX_PER_OWNER: u32 = 100;
29+const MAX_PER_OWNER: u32 = 200;
30+const MAX_NOTE: usize = 500;
1831
1932 #[derive(Deserialize)]
2033 struct SettingRow {
2134 id: String,
2235 scope: String,
36+ kind: String,
2337 name: String,
2438 value: String,
2539 updated_at: String,
40+ available_to: String,
41+ environments: String,
42+ repositories: Option<String>,
43+ note: Option<String>,
44+ updated_by: Option<String>,
2645 }
2746
28−#[derive(Deserialize)]
29−struct Count {
30− n: u32,
31−}
32−
3347 /// A name GitHub would accept: letters, digits and `_`, not starting with
34−/// a digit or `GITHUB_`.
48+/// a digit, `GITHUB_` or `G1T_`, which are g1t's own (`G1T_TOKEN` and its
49+/// alias `GITHUB_TOKEN`).
3550 fn valid_name(name: &str) -> Result<String, String> {
3651 let upper = name.trim().to_ascii_uppercase();
3752 if upper.is_empty() || upper.len() > 100 {
4358 if upper.starts_with(|c: char| c.is_ascii_digit()) {
4459 return Err("A name cannot start with a digit.".to_owned());
4560 }
46− if upper.starts_with("GITHUB_") {
47− return Err("Names starting with GITHUB_ are kept for GitHub's own.".to_owned());
61+ if upper.starts_with("GITHUB_") || upper.starts_with("G1T_") {
62+ return Err("Names starting with G1T_ or GITHUB_ are kept for g1t's own, such as G1T_TOKEN.".to_owned());
4863 }
4964 Ok(upper)
5065 }
5166
67+/// Environments' names: lowercase letters, digits, `-` and `_`, each once.
68+fn valid_environments(list: &[String]) -> Result<Vec<String>, String> {
69+ let mut out: Vec<String> = Vec::new();
70+ for name in list {
71+ let lower = name.trim().to_ascii_lowercase();
72+ if lower.is_empty() {
73+ continue;
74+ }
75+ if lower.len() > 40 || !lower.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
76+ return Err(format!("`{name}` is not an environment's name: up to 40 letters, digits, - and _."));
77+ }
78+ if !out.contains(&lower) {
79+ out.push(lower);
80+ }
81+ }
82+ out.sort();
83+ Ok(out)
84+}
85+
86+fn consumers(list: &[String]) -> Result<Vec<String>, String> {
87+ let mut out: Vec<String> = Vec::new();
88+ for item in list {
89+ let item = item.trim().to_ascii_lowercase();
90+ if !CONSUMERS.contains(&item.as_str()) {
91+ return Err(format!("`{item}` is not a reader: use workflows or deployments."));
92+ }
93+ if !out.contains(&item) {
94+ out.push(item);
95+ }
96+ }
97+ if out.is_empty() {
98+ return Err("Choose who reads it: workflows, deployments, or both.".to_owned());
99+ }
100+ Ok(out)
101+}
102+
103+fn split(list: &str) -> Vec<String> {
104+ list.split(',').filter(|s| !s.is_empty()).map(str::to_owned).collect()
105+}
106+
107+impl SettingRow {
108+ fn environments(&self) -> Vec<String> {
109+ split(&self.environments)
110+ }
111+
112+ fn repositories(&self) -> Vec<String> {
113+ self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default()
114+ }
115+
116+ fn reaches(&self, repo: &str) -> bool {
117+ let list = self.repositories();
118+ list.is_empty() || list.iter().any(|r| r.eq_ignore_ascii_case(repo))
119+ }
120+
121+ /// Whether it and rows for `environments` would both apply somewhere.
122+ fn overlaps(&self, environments: &[String]) -> bool {
123+ let mine = self.environments();
124+ mine.is_empty() == environments.is_empty() && (mine.is_empty() || mine.iter().any(|e| environments.contains(e)))
125+ }
126+
127+ fn describe(self) -> Setting {
128+ Setting {
129+ available_to: split(&self.available_to),
130+ environments: self.environments(),
131+ repositories: self.repositories(),
132+ value: (self.kind == "variable").then_some(self.value),
133+ id: self.id,
134+ name: self.name,
135+ kind: self.kind,
136+ scope: self.scope,
137+ updated_at: self.updated_at,
138+ note: self.note,
139+ updated_by: self.updated_by,
140+ }
141+ }
142+}
143+
52144 /// Where settings live: `(scope, owner)` with the owner a repository id or
53−/// a workspace slug, and whether the actor may change them.
145+/// a workspace slug.
54146 struct Place {
55147 scope: &'static str,
56148 owner: String,
62154 if actor.kind == PrincipalKind::Agent {
63155 return Ok(fail(FailureCode::Forbidden, "Agents cannot read or change secrets and variables."));
64156 }
157+ // A workspace's tokens, G1T_TOKEN among them, read the names but
158+ // never change them: a workflow must not rewrite what it runs with.
159+ if changing && actor.kind == PrincipalKind::Workspace {
160+ return Ok(fail(
161+ FailureCode::Forbidden,
162+ "A workspace's tokens, G1T_TOKEN included, cannot change secrets and variables. Use a person's token or the site.",
163+ ));
164+ }
65165 match (&owner.repo, &owner.workspace) {
66166 (Some(path), _) => {
67167 if !actor.is_member(&path.namespace.to_lowercase()) {
85185 }
86186 }
87187
88− fn kind(kind: &str) -> Outcome<&'static str> {
188+ /// `secret`, `variable`, or `None` for both.
189+ fn kind(kind: &str) -> Outcome<Option<&'static str>> {
89190 match kind {
90− "secret" | "secrets" => Outcome::Ok("secret"),
91− "variable" | "variables" => Outcome::Ok("variable"),
191+ "secret" | "secrets" => Outcome::Ok(Some("secret")),
192+ "variable" | "variables" | "config" => Outcome::Ok(Some("variable")),
193+ "" | "all" => Outcome::Ok(None),
92194 _ => fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."),
93195 }
94196 }
95197
198+ async fn rows(&self, owner: &str) -> Result<Vec<SettingRow>> {
199+ self.db
200+ .prepare("SELECT * FROM settings WHERE owner = ? ORDER BY name, environments")
201+ .bind(&[owner.into()])?
202+ .all()
203+ .await?
204+ .results::<SettingRow>()
205+ }
206+
96207 pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> {
97208 let kind = check!(Self::kind(&a.kind));
98209 let place = check!(self.place(&a.actor, &a.owner, false).await?);
99− // A repository's list shows its workspace's too, which it inherits.
100− let owners: Vec<&str> = if place.scope == "repository" { vec![place.namespace.as_str(), place.owner.as_str()] } else { vec![place.owner.as_str()] };
210+ let repo_name = a.owner.repo.as_ref().map(|r| r.name.clone());
101211 let mut out: Vec<Setting> = Vec::new();
102− for owner in owners {
103− let rows = self
104− .db
105− .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ? ORDER BY name")
106− .bind(&[owner.into(), kind.into()])?
107− .all()
108− .await?
109− .results::<SettingRow>()?;
110− for row in rows {
111− out.retain(|setting| setting.name != row.name);
112− out.push(Setting {
113− name: row.name,
114− value: (kind == "variable").then_some(row.value),
115− scope: row.scope,
116− updated_at: row.updated_at,
117− });
212+ // A repository's list shows the workspace's rows that reach it, but
213+ // for keys it sets itself.
214+ if place.scope == "repository" {
215+ let own: Vec<String> = self.rows(&place.owner).await?.into_iter().map(|row| row.name).collect();
216+ for row in self.rows(&place.namespace.to_lowercase()).await? {
217+ if repo_name.as_deref().is_some_and(|name| row.reaches(name)) && !own.contains(&row.name) {
218+ out.push(row.describe());
219+ }
118220 }
119221 }
120− out.sort_by(|a, b| a.name.cmp(&b.name));
222+ out.extend(self.rows(&place.owner).await?.into_iter().map(SettingRow::describe));
223+ out.retain(|setting| kind.is_none_or(|kind| setting.kind == kind));
224+ out.sort_by(|a, b| a.name.cmp(&b.name).then(a.environments.cmp(&b.environments)));
121225 Ok(Outcome::Ok(out))
122226 }
123227
228+ fn seal(&self, value: &str, id: &str) -> Outcome<String> {
229+ match &self.sealer {
230+ Some(sealer) => Outcome::Ok(sealer.seal(value, id)),
231+ None => fail(FailureCode::Conflict, "Secrets cannot be saved yet: g1t's key for them is not set."),
232+ }
233+ }
234+
124235 pub async fn set_setting(&self, a: SetSettingArgs) -> Result<Outcome<Setting>> {
125− let kind = check!(Self::kind(&a.kind));
236+ let Some(kind) = check!(Self::kind(&a.kind)) else {
237+ return Ok(fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."));
238+ };
126239 let name = match valid_name(&a.name) {
127240 Ok(name) => name,
128241 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
129242 };
130− if a.value.len() > MAX_VALUE_BYTES {
243+ if a.value.as_ref().is_some_and(|v| v.len() > MAX_VALUE_BYTES) {
131244 return Ok(fail(FailureCode::Invalid, "A value is at most 48 KB."));
132245 }
246+ if a.note.as_ref().is_some_and(|n| n.len() > MAX_NOTE) {
247+ return Ok(fail(FailureCode::Invalid, "A note is at most 500 characters."));
248+ }
249+ let readers = match a.available_to.as_deref().map(consumers).transpose() {
250+ Ok(readers) => readers,
251+ Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
252+ };
253+ let environments = match a.environments.as_deref().map(valid_environments).transpose() {
254+ Ok(environments) => environments,
255+ Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
256+ };
133257 let place = check!(self.place(&a.actor, &a.owner, true).await?);
134− let count = self
135− .db
136− .prepare("SELECT COUNT(*) AS n FROM settings WHERE owner = ? AND kind = ? AND name != ?")
137− .bind(&[place.owner.as_str().into(), kind.into(), name.as_str().into()])?
138− .first::<Count>(None)
139− .await?
140− .map_or(0, |c| c.n);
141− if count >= MAX_PER_OWNER {
142− return Ok(fail(FailureCode::Invalid, format!("There can be at most {MAX_PER_OWNER} {kind}s here.")));
258+ if a.repositories.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" {
259+ return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose repositories."));
143260 }
144− let existing = self
145− .db
146− .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ? AND name = ?")
147− .bind(&[place.owner.as_str().into(), kind.into(), name.as_str().into()])?
148− .first::<SettingRow>(None)
149− .await?;
150− let id = existing.map(|row| row.id).unwrap_or_else(|| new_id("set", now_ms()));
151− let value = if kind == "secret" {
152− let Some(sealer) = &self.sealer else {
153− return Ok(fail(FailureCode::Conflict, "Secrets cannot be saved yet: g1t's key for them is not set."));
154− };
155− sealer.seal(&a.value, &id)
156− } else {
157− a.value.clone()
261+ let rows = self.rows(&place.owner).await?;
262+ // A secret and a variable may share a key, as on GitHub, where
263+ // workflows read them apart (`secrets.X`, `vars.X`).
264+ let same_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect();
265+ // The row being changed: by id, else the key's row for every
266+ // environment (GitHub's API names a secret by its key alone).
267+ let existing = match &a.id {
268+ Some(id) => match rows.iter().find(|row| &row.id == id) {
269+ Some(row) => Some(row),
270+ None => return Ok(fail(FailureCode::NotFound, "There is no such row.")),
271+ },
272+ None if a.environments.is_none() => same_key.iter().copied().find(|row| row.environments.is_empty() && row.kind == kind),
273+ None => None,
274+ };
275+ if existing.is_some_and(|row| row.kind == "secret" && kind == "variable") {
276+ return Ok(fail(FailureCode::Invalid, "A secret cannot become config: its value is sealed. Add a config row and remove the secret."));
277+ }
278+ let environments = environments.unwrap_or_else(|| existing.map(SettingRow::environments).unwrap_or_default());
279+ // A key's rows never apply to the same environment twice.
280+ if let Some(clash) = same_key
281+ .iter()
282+ .find(|row| row.kind == kind && existing.is_none_or(|e| e.id != row.id) && row.overlaps(&environments))
283+ {
284+ let at = if environments.is_empty() { "every environment".to_owned() } else { environments.join(", ") };
285+ return Ok(fail(
286+ FailureCode::Conflict,
287+ format!("{name} already has a row for {at} ({}). Edit that row, or choose other environments.", if clash.environments.is_empty() { "every environment" } else { &clash.environments }),
288+ ));
289+ }
290+ if existing.is_none() && rows.len() as u32 >= MAX_PER_OWNER {
291+ return Ok(fail(FailureCode::Invalid, format!("There can be at most {MAX_PER_OWNER} secrets and variables here.")));
292+ }
293+ let id = existing.map(|row| row.id.clone()).unwrap_or_else(|| new_id("set", now_ms()));
294+ let value = match (&a.value, existing) {
295+ (Some(value), _) if kind == "secret" => check!(self.seal(value, &id)),
296+ (Some(value), _) => value.clone(),
297+ // Config becoming a secret: its value is sealed now.
298+ (None, Some(row)) if row.kind == "variable" && kind == "secret" => check!(self.seal(&row.value, &id)),
299+ (None, Some(row)) => row.value.clone(),
300+ (None, None) => return Ok(fail(FailureCode::Invalid, "A new row needs a `value`.")),
158301 };
302+ let available_to = readers
303+ .map(|r| r.join(","))
304+ .or_else(|| existing.map(|row| row.available_to.clone()))
305+ .unwrap_or_else(|| CONSUMERS.join(","));
306+ let repositories: Option<String> = match &a.repositories {
307+ Some(list) if list.is_empty() => None,
308+ Some(list) => Some(serde_json::to_string(list).unwrap_or_default()),
309+ None => existing.and_then(|row| row.repositories.clone()),
310+ };
311+ let note = match &a.note {
312+ Some(note) if note.trim().is_empty() => None,
313+ Some(note) => Some(note.trim().to_owned()),
314+ None => existing.and_then(|row| row.note.clone()),
315+ };
159316 let at = rfc3339(now_ms());
317+ let optional = |v: Option<&str>| v.map_or(JsValue::NULL, JsValue::from);
160318 self.db
161319 .prepare(
162− "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
163− ON CONFLICT (owner, kind, name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
320+ "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by)
321+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
322+ ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, value = excluded.value, updated_at = excluded.updated_at,
323+ available_to = excluded.available_to, environments = excluded.environments,
324+ repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by",
164325 )
165− .bind(&[id.as_str().into(), place.scope.into(), place.owner.as_str().into(), kind.into(), name.as_str().into(), value.into(), at.as_str().into()])?
326+ .bind(&[
327+ id.as_str().into(),
328+ place.scope.into(),
329+ place.owner.as_str().into(),
330+ kind.into(),
331+ name.as_str().into(),
332+ value.into(),
333+ at.as_str().into(),
334+ available_to.as_str().into(),
335+ environments.join(",").into(),
336+ optional(repositories.as_deref()),
337+ optional(note.as_deref()),
338+ a.actor.username.as_str().into(),
339+ ])?
166340 .run()
167341 .await?;
168− Ok(Outcome::Ok(Setting {
169− name,
170− value: (kind == "variable").then_some(a.value),
171− scope: place.scope.to_owned(),
172− updated_at: at,
173− }))
342+ let row = self
343+ .db
344+ .prepare("SELECT * FROM settings WHERE id = ?")
345+ .bind(&[id.as_str().into()])?
346+ .first::<SettingRow>(None)
347+ .await?
348+ .expect("just written");
349+ Ok(Outcome::Ok(row.describe()))
174350 }
175351
176352 pub async fn delete_setting(&self, a: DeleteSettingArgs) -> Result<Outcome<bool>> {
177353 let kind = check!(Self::kind(&a.kind));
178354 let place = check!(self.place(&a.actor, &a.owner, true).await?);
179− let removed = self
180− .db
181− .prepare("DELETE FROM settings WHERE owner = ? AND kind = ? AND name = ? RETURNING id")
182− .bind(&[place.owner.as_str().into(), kind.into(), a.name.trim().to_ascii_uppercase().into()])?
183− .first::<Value>(None)
184− .await?;
185− Ok(match removed {
186− Some(_) => Outcome::Ok(true),
187− None => fail(FailureCode::NotFound, format!("There is no {kind} called {}.", a.name)),
355+ let name = a.name.trim().to_ascii_uppercase();
356+ let removed = match &a.id {
357+ Some(id) => self
358+ .db
359+ .prepare("DELETE FROM settings WHERE owner = ? AND id = ? RETURNING id")
360+ .bind(&[place.owner.as_str().into(), id.as_str().into()])?
361+ .all()
362+ .await?,
363+ None => self
364+ .db
365+ .prepare("DELETE FROM settings WHERE owner = ? AND name = ? AND (?3 IS NULL OR kind = ?3) RETURNING id")
366+ .bind(&[place.owner.as_str().into(), name.as_str().into(), kind.map_or(JsValue::NULL, JsValue::from)])?
367+ .all()
368+ .await?,
369+ };
370+ Ok(if removed.results::<Value>()?.is_empty() {
371+ fail(FailureCode::NotFound, format!("There is nothing called {} here.", a.name))
372+ } else {
373+ Outcome::Ok(true)
188374 })
189375 }
190376
191− async fn resolved(&self, repo_id: &str, namespace: &str, kind: &str) -> Result<Map<String, Value>> {
377+ /// What one reader of a repository gets: per key, the row for
378+ /// `environment`, else the row for every environment; the repository's
379+ /// over its workspace's. No secrets unless `trusted`.
380+ #[allow(clippy::too_many_arguments)]
381+ async fn resolved(
382+ &self,
383+ repo_id: &str,
384+ repo_name: &str,
385+ namespace: &str,
386+ kind: &str,
387+ consumer: &str,
388+ environment: Option<&str>,
389+ trusted: bool,
390+ ) -> Result<Map<String, Value>> {
391+ if kind == "secret" && !trusted {
392+ return Ok(Map::new());
393+ }
394+ let environment = environment.map(str::to_ascii_lowercase);
192395 let mut out = Map::new();
193396 for owner in [namespace.to_lowercase(), repo_id.to_owned()] {
194− let rows = self
195− .db
196− .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ?")
197− .bind(&[owner.into(), kind.into()])?
198− .all()
397+ let rows: Vec<SettingRow> = self
398+ .rows(&owner)
199399 .await?
200− .results::<SettingRow>()?;
201− for row in rows {
400+ .into_iter()
401+ .filter(|row| row.kind == kind)
402+ .filter(|row| split(&row.available_to).iter().any(|r| r == consumer))
403+ .filter(|row| row.scope != "workspace" || row.reaches(repo_name))
404+ .collect();
405+ let mut names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
406+ names.dedup();
407+ for name in names {
408+ let of_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect();
409+ let chosen = environment
410+ .as_deref()
411+ .and_then(|env| of_key.iter().find(|row| row.environments().iter().any(|e| e == env)))
412+ .or_else(|| of_key.iter().find(|row| row.environments.is_empty()));
413+ let Some(row) = chosen else {
414+ // Rows only for other environments: this reader gets
415+ // none, nor the workspace's.
416+ out.remove(name);
417+ continue;
418+ };
202419 let value = if kind == "secret" {
203420 match self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)) {
204421 Some(value) => value,
205422 None => continue,
206423 }
207424 } else {
208− row.value
425+ row.value.clone()
209426 };
210− out.insert(row.name, Value::String(value));
427+ out.insert(name.to_owned(), Value::String(value));
211428 }
212429 }
213430 Ok(out)
214431 }
215432
216− /// The `vars` context of a repository's runs.
217− pub async fn variables_for(&self, repo_id: &str, namespace: &str) -> Result<Map<String, Value>> {
218− self.resolved(repo_id, namespace, "variable").await
433+ /// The `vars` context of a repository's runs. `environment` is the job's
434+ /// `environment:`, when it has one.
435+ pub async fn variables_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> {
436+ let (namespace, name) = repo.split_once('/').unwrap_or((repo, ""));
437+ self.resolved(repo_id, name, namespace, "variable", "workflows", environment, trusted).await
219438 }
220439
221440 /// The `secrets` context of a repository's runs, opened.
222− pub async fn secrets_for(&self, repo_id: &str, namespace: &str) -> Result<Map<String, Value>> {
223− self.resolved(repo_id, namespace, "secret").await
441+ pub async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> {
442+ let (namespace, name) = repo.split_once('/').unwrap_or((repo, ""));
443+ self.resolved(repo_id, name, namespace, "secret", "workflows", environment, trusted).await
224444 }
445+
446+ /// `resolve_settings`, for the deployments service: what a deploy build
447+ /// and its running app get.
448+ pub async fn resolve_settings(&self, a: ResolveSettingsArgs) -> Result<ResolvedSettings> {
449+ let environment = a.environment.as_deref();
450+ Ok(ResolvedSettings {
451+ secrets: self
452+ .resolved(&a.repo_id, &a.repo.name, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted)
453+ .await?,
454+ variables: self
455+ .resolved(&a.repo_id, &a.repo.name, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted)
456+ .await?,
457+ })
458+ }
225459 }
226460
227461 #[cfg(test)]
228462 mod tests {
229− use super::valid_name;
463+ use super::{SettingRow, consumers, valid_environments, valid_name};
230464
465+ fn row(environments: &str) -> SettingRow {
466+ SettingRow {
467+ id: "set_1".into(),
468+ scope: "repository".into(),
469+ kind: "secret".into(),
470+ name: "STRIPE_KEY".into(),
471+ value: String::new(),
472+ updated_at: String::new(),
473+ available_to: "workflows,deployments".into(),
474+ environments: environments.into(),
475+ repositories: None,
476+ note: None,
477+ updated_by: None,
478+ }
479+ }
480+
231481 #[test]
232− fn names_follow_githubs_rules() {
482+ fn names_follow_githubs_rules_and_keep_g1ts_own() {
233483 assert_eq!(valid_name("npm_token").unwrap(), "NPM_TOKEN");
234484 assert!(valid_name("GITHUB_TOKEN").is_err());
485+ assert!(valid_name("G1T_TOKEN").is_err());
235486 assert!(valid_name("1PASSWORD").is_err());
236487 assert!(valid_name("MY-TOKEN").is_err());
237488 assert!(valid_name("").is_err());
238489 }
490+
491+ #[test]
492+ fn environments_and_readers_are_checked() {
493+ assert_eq!(valid_environments(&["Production".into(), "preview".into(), "production".into()]).unwrap(), vec!["preview", "production"]);
494+ assert!(valid_environments(&["staging env".into()]).is_err());
495+ assert_eq!(consumers(&["Deployments".into(), "deployments".into()]).unwrap(), vec!["deployments"]);
496+ assert!(consumers(&["agents".into()]).is_err());
497+ assert!(consumers(&[]).is_err());
498+ }
499+
500+ #[test]
501+ fn a_keys_rows_cannot_share_an_environment() {
502+ assert!(row("production").overlaps(&["production".into(), "preview".into()]));
503+ assert!(!row("production").overlaps(&["preview".into()]));
504+ // One row for every environment, and others for some, live together.
505+ assert!(!row("").overlaps(&["preview".into()]));
506+ assert!(row("").overlaps(&[]));
507+ }
239508 }
+4−2
1212 production INTEGER NOT NULL DEFAULT 1,
1313 build_command TEXT,
1414 output_dir TEXT,
15− -- A JSON object of variables the build runs with.
16− build_env TEXT NOT NULL DEFAULT '{}',
1715 idle_days INTEGER NOT NULL DEFAULT 7,
1816 updated_by TEXT,
1917 updated_at TEXT NOT NULL
5755 log TEXT,
5856 -- SHA-256 of the token the sandbox reports with.
5957 token_hash TEXT,
58+ -- Whether it was built for someone trusted: a member, an agent, or a
59+ -- push. Protected secrets and variables, and every secret, reach only
60+ -- trusted builds and their apps.
61+ trusted INTEGER NOT NULL DEFAULT 0,
6062 build_seconds INTEGER,
6163 created_by TEXT NOT NULL,
6264 created_at TEXT NOT NULL,
+6−1
8989 worker: BuiltWorker,
9090 completionJwt: string | null,
9191 tags: string[],
92+ /** The repository's entries for running apps, over the project's own `vars`. */
93+ runtime: { secrets: Record<string, string>; variables: Record<string, string> } = { secrets: {}, variables: {} },
9294 ): Promise<void> {
9395 const form = new FormData();
9496 const modules = worker.modules?.length ? worker.modules : null;
9597 const assetsBinding = worker.assetsBinding || "ASSETS";
96− const bindings: object[] = Object.entries(worker.vars ?? {}).map(([name, value]) =>
98+ const vars: Record<string, unknown> = { ...(worker.vars ?? {}), ...runtime.variables };
99+ for (const name of Object.keys(runtime.secrets)) delete vars[name];
100+ const bindings: object[] = Object.entries(vars).map(([name, value]) =>
97101 typeof value === "string"
98102 ? { type: "plain_text", name, text: value }
99103 : { type: "json", name, json: value },
100104 );
105+ for (const [name, text] of Object.entries(runtime.secrets)) bindings.push({ type: "secret_text", name, text });
101106 if (completionJwt) bindings.push({ type: "assets", name: assetsBinding });
102107 const assetsConfig: Record<string, string> = {};
103108 if (worker.htmlHandling && HTML_HANDLING.includes(worker.htmlHandling)) {
+58−16
5353 IDENTITY: ServiceBinding;
5454 BILLING: ServiceBinding;
5555 RUNNER: ServiceBinding;
56+ /** Secrets and variables: the actions service holds the one store. */
57+ ACTIONS: ServiceBinding;
5658 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
5759 CLOUDFLARE_API_TOKEN?: string;
5860 CLOUDFLARE_ACCOUNT_ID: string;
6365 /** A build that has not reported in this long has died. */
6466 const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
6567 const LIST_LIMIT = 50;
66−const MAX_ENV_VARS = 50;
6768 const STATUS_CONTEXT = "g1t / deploy";
6869
6970 const now = () => new Date().toISOString();
9192 production: number;
9293 build_command: string | null;
9394 output_dir: string | null;
94− build_env: string;
9595 idle_days: number;
9696 };
9797
109109 warnings: string;
110110 log: string | null;
111111 token_hash: string | null;
112+ trusted: number;
112113 build_seconds: number | null;
113114 created_by: string;
114115 created_at: string;
179180 return response.ok ? ((await response.json()) as RepoPath | null) : null;
180181 }
181182
183+ /**
184+ * What the repository's secrets and variables available to deployments
185+ * give production or a preview: its build's environment, and the same
186+ * again as the running app's bindings. Untrusted builds get no secrets.
187+ */
188+ private async resolve(
189+ repoId: string,
190+ repo: RepoPath,
191+ environment: DeployKind,
192+ trusted: boolean,
193+ ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
194+ const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
195+ method: "POST",
196+ headers: { "content-type": "application/json" },
197+ body: JSON.stringify({ repoId, repo, consumer: "deployments", environment, trusted }),
198+ });
199+ if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
200+ const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
201+ return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
202+ }
203+
204+ /**
205+ * Whether a pull request's author is trusted with the repository's
206+ * secrets: g1t's agent, or a member of the workspace. Someone from
207+ * outside gets a preview built without them, as their workflows run.
208+ */
209+ private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
210+ if (author.kind === "agent" || author.username === "g1t-agent") return true;
211+ // On a private repository only members can open one at all.
212+ const found = await reposClient(this.env.REPOS).get(repo, actor);
213+ if (found.ok && found.value.isPrivate) return true;
214+ if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
215+ const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
216+ return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
217+ }
218+
182219 private async settingsRow(repoId: string): Promise<SettingsRow | null> {
183220 return this.db.prepare("SELECT * FROM settings WHERE repo_id = ?").bind(repoId).first<SettingsRow>();
184221 }
190227 production: row ? !!row.production : true,
191228 buildCommand: row?.build_command ?? null,
192229 outputDir: row?.output_dir ?? null,
193− buildEnv: JSON.parse(row?.build_env ?? "{}") as Record<string, string>,
194230 idleDays: row?.idle_days ?? 7,
195231 productionUrl: appUrl(await scriptName(repo, null)),
196232 };
226262 // Turning it on starts paid work: only with the workspace's plan.
227263 const plan = await billingClient(this.env.BILLING).hasFeature(a.repo.namespace, "deployments");
228264 if (!plan.ok) return plan;
229− }
230− const env = Object.entries(next.buildEnv ?? {});
231− if (env.length > MAX_ENV_VARS) return fail("invalid", `At most ${MAX_ENV_VARS} build variables.`);
232− if (env.some(([name]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) {
233− return fail("invalid", "A variable's name is letters, digits and underscores, not starting with a digit.");
234265 }
235266 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
236267 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
237268 await this.db
238269 .prepare(
239270 `INSERT INTO settings (repo_id, namespace, name, enabled, previews, production, build_command, output_dir,
240− build_env, idle_days, updated_by, updated_at)
241− VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
271+ idle_days, updated_by, updated_at)
272+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
242273 ON CONFLICT (repo_id) DO UPDATE SET namespace = ?2, name = ?3, enabled = ?4, previews = ?5, production = ?6,
243− build_command = ?7, output_dir = ?8, build_env = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`,
274+ build_command = ?7, output_dir = ?8, idle_days = ?9, updated_by = ?10, updated_at = ?11`,
244275 )
245276 .bind(
246277 repo.value.id,
251282 next.production ? 1 : 0,
252283 clip(next.buildCommand),
253284 clip(next.outputDir),
254− JSON.stringify(Object.fromEntries(env.map(([k, v]) => [k, String(v).slice(0, 2000)]))),
255285 idleDays,
256286 a.actor.username,
257287 now(),
371401 reader: User;
372402 createdBy: string;
373403 settings: SettingsRow;
404+ /** A push, or work by a member or an agent; see `trusted`. */
405+ trusted: boolean;
374406 }): Promise<Result<Deployment>> {
375407 const script = await scriptName(input.repo, input.number);
376408 const id = newId("dpl");
385417 await this.db
386418 .prepare(
387419 `INSERT INTO deployments (id, repo_id, namespace, name, kind, number, commit_sha, script, status, error,
388− token_hash, created_by, created_at, finished_at)
389− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
420+ token_hash, trusted, created_by, created_at, finished_at)
421+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
390422 )
391423 .bind(
392424 id,
400432 refused ? "skipped" : "queued",
401433 refused,
402434 refused ? null : await sha256(token),
435+ input.trusted ? 1 : 0,
403436 input.createdBy,
404437 now(),
405438 refused ? now() : null,
415448 .bind(now(), script, id)
416449 .run();
417450 await this.status(input.repoId, input.commit, "pending", "Building", `${this.env.SITE}/${input.repo.namespace}/${input.repo.name}/deployments/${id}`);
418− const env = JSON.parse(input.settings.build_env || "{}") as Record<string, string>;
451+ // What the repository's secrets and variables give builds of this kind.
452+ const build = await this.resolve(input.repoId, input.repo, input.kind, input.trusted);
419453 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
420454 method: "POST",
421455 headers: { "content-type": "application/json" },
427461 commit: input.commit,
428462 buildCommand: input.settings.build_command,
429463 outputDir: input.settings.output_dir,
430− buildEnv: env,
464+ buildEnv: build.variables,
465+ buildSecrets: build.secrets,
431466 }),
432467 });
433468 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
462497 reader: actor,
463498 createdBy,
464499 settings,
500+ // The default branch only moves by people and agents with access.
501+ trusted: true,
465502 });
466503 }
467504
502539 reader: pull.author,
503540 createdBy,
504541 settings,
542+ trusted: await this.insider(repo, pull.author, actor),
505543 });
506544 }
507545
541579 const worker = (body.worker ?? {}) as BuiltWorker;
542580 const seconds = Number(body.buildSeconds) || 0;
543581 try {
582+ // Running apps' secrets and variables are bound here, by g1t:
583+ // they never pass through the build's sandbox.
584+ const runtime = await this.resolve(row.repo_id, { namespace: row.namespace, name: row.name }, row.kind, !!row.trusted);
544585 await cloudflare.putScript(
545586 row.script,
546587 worker,
547588 typeof body.completionJwt === "string" ? body.completionJwt : null,
548589 [`workspace:${row.namespace}`, `repo:${row.namespace}/${row.name}`, row.kind],
590+ runtime,
549591 );
550592 } catch (error) {
551593 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
+3−1
2020 { "binding": "WORK", "service": "g1t-work" },
2121 { "binding": "IDENTITY", "service": "g1t-identity" },
2222 { "binding": "BILLING", "service": "g1t-billing" },
23− { "binding": "RUNNER", "service": "g1t-runner" }
23+ { "binding": "RUNNER", "service": "g1t-runner" },
24+ // Secrets and variables, with who may read each.
25+ { "binding": "ACTIONS", "service": "g1t-actions" }
2426 ],
2527 // Pull requests opened, pushed to, closed and merged; pushes to the
2628 // default branch.
+4−0
124124 commit: string;
125125 buildCommand?: string | null;
126126 outputDir?: string | null;
127+ /** The repository's variables for deploy builds. */
127128 buildEnv?: Record<string, string>;
129+ /** Its secrets for deploy builds: set like variables, and redacted from the log. */
130+ buildSecrets?: Record<string, string>;
128131 };
129132
130133 /** Long enough to install and build; then the read token stops working. */
697700 BUILD_COMMAND: job.buildCommand ?? "",
698701 OUTPUT_DIR: job.outputDir ?? "",
699702 BUILD_ENV: JSON.stringify(job.buildEnv ?? {}),
703+ BUILD_SECRETS: JSON.stringify(job.buildSecrets ?? {}),
700704 },
701705 });
702706 } catch (error) {