Commit

Secrets and variables: one list, rows per environment, for workflows and deployments

Shaped like Vercel's environment variables, after looking at how GitHub, GitLab, Vercel, Netlify, Railway, Cloudflare, CircleCI and Doppler split the same question. Each row is a key, its type (Secret, or Config, which can become a secret but never back), a note, the environments it applies to (all, or production, preview, or a workflow job's `environment:`) and who reads it: Workflows, Deployments, or both. A key can hold a row per environment, so production gets the live key and previews the test one. A workspace's rows reach every repository or the ones linked; a repository's row of the same key wins. - Actions service: migration 0003 rebuilds `settings` as rows (no unique key; a key's rows of one type never overlap); `resolve_settings` for the deployments service. A reader gets each key's row for its environment, else the row for all. Untrusted runs (pull requests from outside the workspace) get no secrets. Workflow jobs read the row for their `environment:`. - G1T_TOKEN: every trusted job gets the workspace's own token as `secrets.G1T_TOKEN`, with `GITHUB_TOKEN` (and `github.token`) as its alias. Keys starting with G1T_ or GITHUB_ are g1t's own. - Workspace tokens, G1T_TOKEN included, can no longer change secrets and variables; before, a trusted workflow's token could rewrite a repository's. - Deployments: builds get the rows available to Deployments as their environment (secrets hidden in the log); the running app gets them as bindings, put in place by g1t, never through the build's sandbox. "Build variables" on the Deployments page are gone. - API and MCP: GitHub's routes unchanged, with `id`, `environments`, `availableTo`, `repositories` and `note` added. - Site: one list with search and type and environment filters; Add and Edit in a side panel with Secret/Config cards; pasting a .env file adds many. Deployment settings move to Settings → Deployments. Settings are tidied: one icon per thing (a lock for secrets, a key for tokens), "Members" everywhere, "Billing and plans", workspace settings in one order, breadcrumbs that name a repository's settings page. - Docs: guides/secrets-and-variables (rows, environments, who reads what, who gets secrets, G1T_TOKEN, the API); Actions, Deployments, Workspaces, MCP and llms.txt updated.

syntaqxcommitted Parent0e0be5fBrowse files
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) {