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.
| 576 | 576 | } | |
| 577 | 577 | Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Members only.", | |
| 578 | 578 | 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." | |
| 580 | 580 | } | |
| 581 | 581 | 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." | |
| 583 | 583 | } | |
| 584 | − | Op::DeleteActionsSecret => "Remove a secret.", | |
| 584 | + | Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.", | |
| 585 | 585 | 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." | |
| 587 | 587 | } | |
| 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.", | |
| 590 | 590 | Op::ImportIssue => { | |
| 591 | 591 | "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." | |
| 592 | 592 | } | |
| 986 | 986 | Op::ListActionsSecrets | Op::ListActionsVariables => object(settings_owner(json!({})), &[]), | |
| 987 | 987 | Op::SetActionsSecret | Op::SetActionsVariable => object( | |
| 988 | 988 | 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." }, | |
| 991 | 1008 | })), | |
| 992 | − | &["setting", "value"], | |
| 1009 | + | &["setting"], | |
| 993 | 1010 | ), | |
| 994 | 1011 | 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 | + | })), | |
| 996 | 1016 | &["setting"], | |
| 997 | 1017 | ), | |
| 998 | 1018 | Op::CreateWebhook => object( | |
| 1709 | 1729 | args["kind"] = json!(kind); | |
| 1710 | 1730 | // GitHub's variables API names the variable in the body as `name`. | |
| 1711 | 1731 | 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 | + | } | |
| 1713 | 1747 | let method = match self { | |
| 1714 | 1748 | Op::ListActionsSecrets | Op::ListActionsVariables => "settings", | |
| 1715 | 1749 | Op::SetActionsSecret | Op::SetActionsVariable => "set_setting", |
| 84 | 84 | { label: 'Model providers', slug: 'guides/models' }, | |
| 85 | 85 | { label: 'Webhooks', slug: 'guides/webhooks' }, | |
| 86 | 86 | { label: 'GitHub Actions', slug: 'guides/actions' }, | |
| 87 | + | { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' }, | |
| 87 | 88 | ], | |
| 88 | 89 | }, | |
| 89 | 90 | { |
| 37 | 37 | | `run:` with `bash`, `sh`, `python` or a custom shell | The same. | | |
| 38 | 38 | | JavaScript actions (`uses: owner/repo@v7`) | Fetched from GitHub and run as they are, on Node 24, the runtime current actions declare. | | |
| 39 | 39 | | 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. | | |
| 41 | 41 | | `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. | | |
| 42 | 42 | | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. | | |
| 43 | 43 | | `::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. | | |
| 45 | 46 | | `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. | | |
| 46 | 47 | | `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. | | |
| 47 | 48 | ||
| 57 | 58 | - **The toolkit's own cache.** Actions that cache through GitHub's service | |
| 58 | 59 | themselves, such as `actions/setup-node` with `cache: npm`, run without | |
| 59 | 60 | 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. | |
| 62 | 65 | ||
| 63 | 66 | ## The runner | |
| 64 | 67 | ||
| 105 | 108 | ||
| 106 | 109 | ## Secrets and variables | |
| 107 | 110 | ||
| 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. | |
| 114 | 117 | ||
| 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. | |
| 118 | 122 | ||
| 119 | 123 | ## Who may run workflows | |
| 120 | 124 | ||
| 144 | 148 | | `list_actions_variables`, `set_actions_variable`, `delete_actions_variable` | `GET` and `POST /repos/{owner}/{repo}/actions/variables`, `PATCH` and `DELETE …/variables/{name}` | | |
| 145 | 149 | ||
| 146 | 150 | 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). | |
| 150 | 154 | ||
| 151 | 155 | ```sh | |
| 152 | 156 | curl -X POST https://api.g1t.sh/repos/acme/web/actions/workflows/ci.yml/dispatches \ |
| 71 | 71 | - Your Worker's `fetch` handler runs as written, and its static assets | |
| 72 | 72 | are served under the binding name your config gives them. Cron triggers | |
| 73 | 73 | 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. | |
| 75 | 78 | - **Not provisioned yet:** D1, KV, R2, Durable Objects, Queues, service | |
| 76 | 79 | bindings, Vectorize, Hyperdrive, Workers AI and Workflows. A project that | |
| 77 | 80 | declares any of them still deploys, without them, and its deployment | |
| 120 | 123 | ||
| 121 | 124 | ## Settings | |
| 122 | 125 | ||
| 123 | − | On the repository's **Deployments** page, under **Settings**: | |
| 126 | + | Under the repository's **Settings → Deployments**, | |
| 127 | + | `g1t.sh/<workspace>/<repo>/settings/deployments`: | |
| 124 | 128 | ||
| 125 | 129 | | Setting | Default | | | |
| 126 | 130 | | --- | --- | --- | | |
| 129 | 133 | | Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. | | |
| 130 | 134 | | Output directory | Found by itself | What a static site serves. | | |
| 131 | 135 | | 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. | | |
| 133 | 136 | ||
| 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 | + | ||
| 134 | 157 | ## What it costs | |
| 135 | 158 | ||
| 136 | 159 | Deployments are never free, including while the rest of g1t is. | |
| 175 | 198 | ||
| 176 | 199 | ## Turn it off | |
| 177 | 200 | ||
| 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 | |
| 180 | 203 | rebuilds production. | |
| 181 | 204 | - **For the workspace:** an owner chooses **Turn off at the end of the | |
| 182 | 205 | period** under the plan on Billing. Deployments keep working until the |
| 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. |
| 60 | 60 | | --- | --- | --- | | |
| 61 | 61 | | **General** | Owners | The display name and a one-line description. | | |
| 62 | 62 | | **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. | | |
| 65 | 63 | | **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**. | |
| 66 | 71 | ||
| 67 | 72 | The arrow at the top of the settings goes back. | |
| 68 | 73 |
| 146 | 146 | | `cancel_workflow_run` | `repo`, `id` | Cancel a run. Members only. | `POST /repos/{owner}/{name}/actions/runs/{id}/cancel` | | |
| 147 | 147 | | `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` | | |
| 148 | 148 | | `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}` | | |
| 155 | 155 | ||
| 156 | 156 | ## Messages | |
| 157 | 157 |
| 1 | − | import { KeyRound, Settings, Webhook } from "lucide-react"; | |
| 1 | + | import { Lock, Rocket, Settings, Webhook } from "lucide-react"; | |
| 2 | 2 | ||
| 3 | 3 | import { TabLink } from "./ui"; | |
| 4 | 4 | ||
| 9 | 9 | <TabLink to={`${base}/settings`} end icon={<Settings size={15} />}> | |
| 10 | 10 | General | |
| 11 | 11 | </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 | |
| 14 | 14 | </TabLink> | |
| 15 | − | <TabLink to={`${base}/settings/secrets`} icon={<KeyRound size={15} />}> | |
| 15 | + | <TabLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}> | |
| 16 | 16 | Secrets and variables | |
| 17 | 17 | </TabLink> | |
| 18 | + | <TabLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}> | |
| 19 | + | Webhooks | |
| 20 | + | </TabLink> | |
| 18 | 21 | </nav> | |
| 19 | 22 | ); | |
| 20 | 23 | } |
| 1 | 1 | /** | |
| 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. | |
| 5 | 7 | */ | |
| 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"; | |
| 9 | 11 | ||
| 10 | − | import type { Setting, SettingKind } from "@g1t/contracts"; | |
| 12 | + | import type { Setting } from "@g1t/contracts"; | |
| 11 | 13 | ||
| 12 | 14 | 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"; | |
| 14 | 16 | ||
| 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(", "); | |
| 47 | 25 | } | |
| 48 | 26 | ||
| 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, | |
| 52 | 33 | scope, | |
| 53 | 34 | manage, | |
| 54 | − | action, | |
| 55 | 35 | }: { | |
| 56 | − | kind: SettingKind; | |
| 57 | − | items: Setting[]; | |
| 36 | + | data: SecretsData; | |
| 37 | + | action: SecretsAction | undefined; | |
| 58 | 38 | scope: "repository" | "workspace"; | |
| 59 | 39 | manage: boolean; | |
| 60 | − | action: SecretsAction | undefined; | |
| 61 | 40 | }) { | |
| 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 | + | ||
| 70 | 60 | 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> | |
| 87 | 83 | )} | |
| 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. | |
| 89 | 90 | </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 | + | ||
| 90 | 121 | <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" /> | |
| 93 | 128 | ) : ( | |
| 94 | 129 | <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} /> | |
| 97 | 132 | ))} | |
| 98 | 133 | </ul> | |
| 99 | 134 | )} | |
| 100 | 135 | </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 | + | </> | |
| 113 | 198 | )} | |
| 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> | |
| 125 | 203 | ); | |
| 126 | 204 | } | |
| 127 | 205 | ||
| 128 | − | export function SecretsPanel({ | |
| 129 | − | data, | |
| 130 | − | action, | |
| 206 | + | function Drawer({ | |
| 207 | + | row, | |
| 131 | 208 | scope, | |
| 132 | − | manage, | |
| 209 | + | repositories, | |
| 210 | + | error, | |
| 133 | 211 | }: { | |
| 134 | − | data: SecretsData; | |
| 135 | − | action: SecretsAction | undefined; | |
| 212 | + | row: Setting | undefined; | |
| 136 | 213 | scope: "repository" | "workspace"; | |
| 137 | − | manage: boolean; | |
| 214 | + | repositories: string[]; | |
| 215 | + | error: string | undefined; | |
| 138 | 216 | }) { | |
| 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"; | |
| 139 | 225 | 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> | |
| 144 | 422 | </div> | |
| 145 | 423 | ); | |
| 146 | 424 | } |
| 13 | 13 | CreditCard, | |
| 14 | 14 | GitPullRequest, | |
| 15 | 15 | History, | |
| 16 | + | Fingerprint, | |
| 16 | 17 | KeyRound, | |
| 17 | 18 | Layers, | |
| 18 | 19 | LayoutDashboard, | |
| 290 | 291 | <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}> | |
| 291 | 292 | Members | |
| 292 | 293 | </SidebarLink> | |
| 294 | + | <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}> | |
| 295 | + | Access tokens | |
| 296 | + | </SidebarLink> | |
| 293 | 297 | <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}> | |
| 294 | − | Billing | |
| 298 | + | Billing and plans | |
| 295 | 299 | </SidebarLink> | |
| 296 | 300 | <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}> | |
| 297 | 301 | Integrations | |
| 298 | − | </SidebarLink> | |
| 299 | − | <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}> | |
| 300 | − | Webhooks | |
| 301 | 302 | </SidebarLink> | |
| 302 | 303 | <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}> | |
| 303 | 304 | Secrets and variables | |
| 304 | 305 | </SidebarLink> | |
| 305 | − | <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}> | |
| 306 | − | Access tokens | |
| 306 | + | <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}> | |
| 307 | + | Webhooks | |
| 307 | 308 | </SidebarLink> | |
| 308 | 309 | </SidebarGroup> | |
| 309 | 310 | </nav> | |
| 433 | 434 | Mission control | |
| 434 | 435 | </Link> | |
| 435 | 436 | <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")} | |
| 438 | 439 | {item("applications", <Plug size={15} />, "Connected applications")} | |
| 439 | 440 | </SidebarGroup> | |
| 440 | 441 | </nav> | |
| 619 | 620 | people: "Members", | |
| 620 | 621 | tokens: "Access tokens", | |
| 621 | 622 | usage: "Usage", | |
| 622 | − | billing: "Billing", | |
| 623 | + | billing: "Billing and plans", | |
| 623 | 624 | integrations: "Integrations", | |
| 624 | 625 | webhooks: "Webhooks", | |
| 625 | 626 | tree: "Code", | |
| 655 | 656 | if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` }); | |
| 656 | 657 | else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` }); | |
| 657 | 658 | 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 | + | } | |
| 659 | 666 | } | |
| 660 | 667 | return ( | |
| 661 | 668 | <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm"> |
| 1 | − | import type { Setting, SettingKind, SettingsOwner, User } from "@g1t/contracts"; | |
| 1 | + | import { redirect } from "react-router"; | |
| 2 | 2 | ||
| 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"; | |
| 4 | 6 | ||
| 5 | 7 | 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[]; | |
| 8 | 11 | error: string | null; | |
| 9 | 12 | }; | |
| 10 | 13 | ||
| 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. */ | |
| 12 | 15 | 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), | |
| 16 | 19 | ]); | |
| 17 | 20 | 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, | |
| 21 | 24 | }; | |
| 22 | 25 | } | |
| 23 | 26 | ||
| 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 | + | } | |
| 25 | 43 | ||
| 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> { | |
| 27 | 49 | 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; | |
| 31 | 51 | 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." }; | |
| 34 | 69 | } | |
| 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(); | |
| 35 | 76 | 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); | |
| 40 | 93 | } |
| 53 | 53 | route("settings", "routes/repo/settings.tsx"), | |
| 54 | 54 | route("settings/webhooks", "routes/repo/webhooks.tsx"), | |
| 55 | 55 | route("settings/secrets", "routes/repo/secrets.tsx"), | |
| 56 | + | route("settings/deployments", "routes/repo/settings-deployments.tsx"), | |
| 56 | 57 | ]), | |
| 57 | 58 | // Anything else: a 404 that still knows who is signed in. | |
| 58 | 59 | route("*", "routes/not-found.tsx"), |
| 5 | 5 | import type { Deployment, DeployStatus, FeatureState } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | 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"; | |
| 9 | 9 | import { billing, deployments } from "../../lib/services.server"; | |
| 10 | 10 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 11 | 11 | ||
| 28 | 28 | return { role, settings: unwrap(settings), ...unwrap(list), plan }; | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 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 | − | ||
| 43 | 31 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 44 | 32 | assertSameOrigin(request); | |
| 45 | 33 | const user = requireUser(context, request); | |
| 61 | 49 | ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." } | |
| 62 | 50 | : { error: saved.error.message }; | |
| 63 | 51 | } | |
| 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." }; | |
| 74 | 53 | } | |
| 75 | 54 | ||
| 76 | 55 | const STATUS: Record<DeployStatus, { label: string; tone: string }> = { | |
| 201 | 180 | )} | |
| 202 | 181 | </div> | |
| 203 | 182 | ||
| 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> | |
| 218 | 194 | </> | |
| 219 | 195 | )} | |
| 220 | 196 | </div> | |
| 323 | 299 | </span> | |
| 324 | 300 | </Link> | |
| 325 | 301 | </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> | |
| 373 | 302 | ); | |
| 374 | 303 | } |
| 19 | 19 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 20 | 20 | assertSameOrigin(request); | |
| 21 | 21 | 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); | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | export default function RepoSecrets({ loaderData, actionData, params }: Route.ComponentProps) { |
| 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 | + | } |
| 28 | 28 | about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.", | |
| 29 | 29 | }, | |
| 30 | 30 | 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." }, | |
| 32 | 32 | webhooks: { | |
| 33 | 33 | title: "Webhooks", | |
| 34 | 34 | about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.", | |
| 35 | 35 | }, | |
| 36 | 36 | secrets: { | |
| 37 | 37 | 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.", | |
| 39 | 39 | }, | |
| 40 | 40 | integrations: { | |
| 41 | 41 | title: "Integrations", | |
| 101 | 101 | icon={<Users size={15} />} | |
| 102 | 102 | count={workspace.memberCount} | |
| 103 | 103 | > | |
| 104 | − | People | |
| 104 | + | Members | |
| 105 | 105 | </TabLink> | |
| 106 | 106 | <TabLink to={`${base}/-/tokens`} icon={<KeyRound size={15} />}> | |
| 107 | 107 | Access tokens |
| 16 | 16 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 17 | 17 | assertSameOrigin(request); | |
| 18 | 18 | 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); | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | export default function WorkspaceSecrets({ loaderData, actionData }: Route.ComponentProps) { |
| 241 | 241 | Runs, jobs and logs are at GitHub's own routes under | |
| 242 | 242 | `{repo}/actions/...`. A run on a pull request's head is a check: pending | |
| 243 | 243 | 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/ | |
| 245 | 254 | ||
| 246 | 255 | ## Deployments | |
| 247 | 256 | ||
| 249 | 258 | workspace's Billing ($5 a month: 10 apps, 1M requests, 3M CPU ms; builds | |
| 250 | 259 | and usage past that from credit at cost + 20%; never free). Then a member | |
| 251 | 260 | 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 | |
| 253 | 265 | `https://pr-<n>--<repo>--<owner>.g1t.page`, shown on it as the check | |
| 254 | 266 | `g1t / deploy`; the default branch deploys to | |
| 255 | 267 | `https://<repo>--<owner>.g1t.page` on each push. Workers projects |
| 402 | 402 | note(Severity::Warning, Some(id), "`container`: steps run on g1t's runner image instead of that container.".to_owned()); | |
| 403 | 403 | } | |
| 404 | 404 | 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()); | |
| 406 | 406 | } | |
| 407 | 407 | let (matrix, fail_fast, max_parallel) = match spec.get("strategy") { | |
| 408 | 408 | Some(Value::Object(strategy)) => ( |
| 155 | 155 | pub done: bool, | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 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. | |
| 159 | 167 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 160 | 168 | #[serde(rename_all = "camelCase")] | |
| 161 | 169 | pub struct Setting { | |
| 170 | + | #[serde(default)] | |
| 171 | + | pub id: String, | |
| 162 | 172 | 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. | |
| 164 | 177 | pub value: Option<String>, | |
| 165 | 178 | /// `repository` or `workspace`. | |
| 166 | 179 | pub scope: String, | |
| 167 | 180 | 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>, | |
| 168 | 195 | } | |
| 169 | 196 | ||
| 170 | 197 | // --- Methods --------------------------------------------------------------- | |
| 278 | 305 | pub actor: User, | |
| 279 | 306 | #[serde(flatten)] | |
| 280 | 307 | pub owner: SettingsOwner, | |
| 308 | + | /// `secret` or `variable`. Changing a variable's row to `secret` seals | |
| 309 | + | /// it; a secret cannot become a variable. | |
| 281 | 310 | pub kind: String, | |
| 282 | 311 | 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>, | |
| 284 | 355 | } | |
| 285 | 356 | ||
| 286 | 357 | /// `delete_setting`. Returns `Outcome<bool>`. | |
| 291 | 362 | pub owner: SettingsOwner, | |
| 292 | 363 | pub kind: String, | |
| 293 | 364 | pub name: String, | |
| 365 | + | /// One row; left out, every row of the key. | |
| 366 | + | #[serde(default)] | |
| 367 | + | pub id: Option<String>, | |
| 294 | 368 | } | |
| 295 | 369 | ||
| 296 | 370 | /// `job_spec` and `job_report`: the sandbox running a job, with the job's |
| 23 | 23 | //! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report. | |
| 24 | 24 | //! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out. | |
| 25 | 25 | //! - `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. | |
| 27 | 29 | ||
| 28 | 30 | use std::collections::BTreeMap; | |
| 29 | 31 | use std::path::{Path, PathBuf}; | |
| 536 | 538 | ||
| 537 | 539 | fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> { | |
| 538 | 540 | 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 | + | } | |
| 547 | 551 | } | |
| 548 | 552 | } | |
| 549 | 553 | } | |
| 598 | 602 | return 2; | |
| 599 | 603 | } | |
| 600 | 604 | }; | |
| 601 | − | let secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"] | |
| 605 | + | let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"] | |
| 602 | 606 | .iter() | |
| 603 | 607 | .filter_map(|name| std::env::var(name).ok()) | |
| 604 | 608 | .filter(|secret| !secret.is_empty()) | |
| 605 | 609 | .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 | + | } | |
| 606 | 614 | if let Err(error) = reporter.send("started", json!({})) { | |
| 607 | 615 | eprintln!("g1t-runner: {error:#}"); | |
| 608 | 616 | return 1; |
| 96 | 96 | export type LogChunk = { seq: number; step: number; text: string }; | |
| 97 | 97 | export type JobLog = { chunks: LogChunk[]; done: boolean }; | |
| 98 | 98 | ||
| 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 | + | */ | |
| 99 | 112 | export type Setting = { | |
| 113 | + | id: string; | |
| 100 | 114 | 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. */ | |
| 102 | 118 | value: string | null; | |
| 103 | 119 | scope: "repository" | "workspace"; | |
| 104 | 120 | 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; | |
| 105 | 129 | }; | |
| 106 | 130 | ||
| 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 | + | ||
| 107 | 141 | export type SettingsOwner = { repo: RepoPath } | { workspace: string }; | |
| 108 | 142 | export type SettingKind = "secret" | "variable"; | |
| 143 | + | /** `all` lists both. */ | |
| 144 | + | export type SettingKindFilter = SettingKind | "all"; | |
| 109 | 145 | ||
| 110 | 146 | export type RunsFilter = { | |
| 111 | 147 | workflow?: string; | |
| 131 | 167 | cancel(actor: User, repo: RepoPath, id: string): Promise<Result<WorkflowRun>>; | |
| 132 | 168 | rerun(actor: User, repo: RepoPath, id: string, failedOnly?: boolean): Promise<Result<WorkflowRun>>; | |
| 133 | 169 | 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>>; | |
| 137 | 182 | } |
| 256 | 256 | setWorkflowEnabled: (actor, repo, workflow, enabled) => | |
| 257 | 257 | call("set_workflow_enabled", { actor, repo, workflow, enabled }), | |
| 258 | 258 | 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 }), | |
| 261 | 262 | }; | |
| 262 | 263 | } | |
| 263 | 264 |
| 25 | 25 | buildCommand: string | null; | |
| 26 | 26 | /** What to serve, for a static site; found by itself when null. */ | |
| 27 | 27 | outputDir: string | null; | |
| 28 | − | /** Variables the build runs with. Not secret: shown to members. */ | |
| 29 | − | buildEnv: Record<string, string>; | |
| 30 | 28 | /** A preview no one has visited in this many days is taken down. */ | |
| 31 | 29 | idleDays: number; | |
| 32 | 30 | /** Where production is served. */ |
| 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); |
| 178 | 178 | "settings" => reply(&service.settings(args(body)?).await?), | |
| 179 | 179 | "set_setting" => reply(&service.set_setting(args(body)?).await?), | |
| 180 | 180 | "delete_setting" => reply(&service.delete_setting(args(body)?).await?), | |
| 181 | + | "resolve_settings" => reply(&service.resolve_settings(args(body)?).await?), | |
| 181 | 182 | "job_spec" => reply(&service.job_spec(args(body)?).await?), | |
| 182 | 183 | "job_auth" => reply(&service.job_auth(args(body)?).await?), | |
| 183 | 184 | "job_report" => reply(&service.job_report(args(body)?).await?), |
| 237 | 237 | info.workflow_path = new.path.clone(); | |
| 238 | 238 | info.run_id = id.clone(); | |
| 239 | 239 | 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?; | |
| 241 | 243 | ||
| 242 | 244 | // run-name and the concurrency group read github, inputs and vars. | |
| 243 | 245 | let mut contexts = Map::new(); | |
| 474 | 476 | /// Decides on one job whose needs are done: skip it, fail it, or expand | |
| 475 | 477 | /// it into its matrix and queue it. | |
| 476 | 478 | 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?; | |
| 478 | 480 | let mut contexts = Self::base_contexts(run, &vars, &job.id); | |
| 479 | 481 | // A called workflow's jobs read the inputs they were called with. | |
| 480 | 482 | let call = row.call(); | |
| 1232 | 1234 | let spec = &spec; | |
| 1233 | 1235 | let repo = repo_path(&run.repo); | |
| 1234 | 1236 | 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. | |
| 1236 | 1247 | let token = if trusted { | |
| 1237 | 1248 | match self.workspace_actor(&repo.namespace).await? { | |
| 1238 | 1249 | Some(workspace) => { | |
| 1241 | 1252 | "create_access_token", | |
| 1242 | 1253 | &CreateAccessTokenArgs { | |
| 1243 | 1254 | user: workspace, | |
| 1244 | − | name: format!("GITHUB_TOKEN for {} run {}", run.repo, run.number), | |
| 1255 | + | name: format!("G1T_TOKEN for {} run {}", run.repo, run.number), | |
| 1245 | 1256 | ttl_seconds: Some(u64::from(job.timeout_minutes) * 60 + 600), | |
| 1246 | 1257 | }, | |
| 1247 | 1258 | ) | |
| 1253 | 1264 | } else { | |
| 1254 | 1265 | String::new() | |
| 1255 | 1266 | }; | |
| 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())); | |
| 1257 | 1275 | secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone())); | |
| 1258 | 1276 | 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?; | |
| 1260 | 1278 | ||
| 1261 | 1279 | let jobs = self.job_rows(&run.id).await?; | |
| 1262 | 1280 | let mut needs = Map::new(); |
| 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. | |
| 4 | 12 | ||
| 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 | + | }; | |
| 6 | 17 | use g1t_contracts::time::rfc3339; | |
| 7 | 18 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; | |
| 8 | 19 | use g1t_kit::now_ms; | |
| 9 | 20 | use serde::Deserialize; | |
| 10 | 21 | use serde_json::{Map, Value}; | |
| 11 | 22 | use worker::Result; | |
| 23 | + | use worker::wasm_bindgen::JsValue; | |
| 12 | 24 | ||
| 13 | 25 | use crate::{Actions, check, fail}; | |
| 14 | 26 | ||
| 15 | 27 | /// The largest value, as on GitHub. | |
| 16 | 28 | 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; | |
| 18 | 31 | ||
| 19 | 32 | #[derive(Deserialize)] | |
| 20 | 33 | struct SettingRow { | |
| 21 | 34 | id: String, | |
| 22 | 35 | scope: String, | |
| 36 | + | kind: String, | |
| 23 | 37 | name: String, | |
| 24 | 38 | value: String, | |
| 25 | 39 | updated_at: String, | |
| 40 | + | available_to: String, | |
| 41 | + | environments: String, | |
| 42 | + | repositories: Option<String>, | |
| 43 | + | note: Option<String>, | |
| 44 | + | updated_by: Option<String>, | |
| 26 | 45 | } | |
| 27 | 46 | ||
| 28 | − | #[derive(Deserialize)] | |
| 29 | − | struct Count { | |
| 30 | − | n: u32, | |
| 31 | − | } | |
| 32 | − | ||
| 33 | 47 | /// 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`). | |
| 35 | 50 | fn valid_name(name: &str) -> Result<String, String> { | |
| 36 | 51 | let upper = name.trim().to_ascii_uppercase(); | |
| 37 | 52 | if upper.is_empty() || upper.len() > 100 { | |
| 43 | 58 | if upper.starts_with(|c: char| c.is_ascii_digit()) { | |
| 44 | 59 | return Err("A name cannot start with a digit.".to_owned()); | |
| 45 | 60 | } | |
| 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()); | |
| 48 | 63 | } | |
| 49 | 64 | Ok(upper) | |
| 50 | 65 | } | |
| 51 | 66 | ||
| 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 | + | ||
| 52 | 144 | /// 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. | |
| 54 | 146 | struct Place { | |
| 55 | 147 | scope: &'static str, | |
| 56 | 148 | owner: String, | |
| 62 | 154 | if actor.kind == PrincipalKind::Agent { | |
| 63 | 155 | return Ok(fail(FailureCode::Forbidden, "Agents cannot read or change secrets and variables.")); | |
| 64 | 156 | } | |
| 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 | + | } | |
| 65 | 165 | match (&owner.repo, &owner.workspace) { | |
| 66 | 166 | (Some(path), _) => { | |
| 67 | 167 | if !actor.is_member(&path.namespace.to_lowercase()) { | |
| 85 | 185 | } | |
| 86 | 186 | } | |
| 87 | 187 | ||
| 88 | − | fn kind(kind: &str) -> Outcome<&'static str> { | |
| 188 | + | /// `secret`, `variable`, or `None` for both. | |
| 189 | + | fn kind(kind: &str) -> Outcome<Option<&'static str>> { | |
| 89 | 190 | 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), | |
| 92 | 194 | _ => fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."), | |
| 93 | 195 | } | |
| 94 | 196 | } | |
| 95 | 197 | ||
| 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 | + | ||
| 96 | 207 | pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> { | |
| 97 | 208 | let kind = check!(Self::kind(&a.kind)); | |
| 98 | 209 | 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()); | |
| 101 | 211 | 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 | + | } | |
| 118 | 220 | } | |
| 119 | 221 | } | |
| 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))); | |
| 121 | 225 | Ok(Outcome::Ok(out)) | |
| 122 | 226 | } | |
| 123 | 227 | ||
| 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 | + | ||
| 124 | 235 | 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 | + | }; | |
| 126 | 239 | let name = match valid_name(&a.name) { | |
| 127 | 240 | Ok(name) => name, | |
| 128 | 241 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 129 | 242 | }; | |
| 130 | − | if a.value.len() > MAX_VALUE_BYTES { | |
| 243 | + | if a.value.as_ref().is_some_and(|v| v.len() > MAX_VALUE_BYTES) { | |
| 131 | 244 | return Ok(fail(FailureCode::Invalid, "A value is at most 48 KB.")); | |
| 132 | 245 | } | |
| 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 | + | }; | |
| 133 | 257 | 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.")); | |
| 143 | 260 | } | |
| 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`.")), | |
| 158 | 301 | }; | |
| 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 | + | }; | |
| 159 | 316 | let at = rfc3339(now_ms()); | |
| 317 | + | let optional = |v: Option<&str>| v.map_or(JsValue::NULL, JsValue::from); | |
| 160 | 318 | self.db | |
| 161 | 319 | .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", | |
| 164 | 325 | ) | |
| 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 | + | ])? | |
| 166 | 340 | .run() | |
| 167 | 341 | .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())) | |
| 174 | 350 | } | |
| 175 | 351 | ||
| 176 | 352 | pub async fn delete_setting(&self, a: DeleteSettingArgs) -> Result<Outcome<bool>> { | |
| 177 | 353 | let kind = check!(Self::kind(&a.kind)); | |
| 178 | 354 | 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) | |
| 188 | 374 | }) | |
| 189 | 375 | } | |
| 190 | 376 | ||
| 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); | |
| 192 | 395 | let mut out = Map::new(); | |
| 193 | 396 | 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) | |
| 199 | 399 | .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 | + | }; | |
| 202 | 419 | let value = if kind == "secret" { | |
| 203 | 420 | match self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)) { | |
| 204 | 421 | Some(value) => value, | |
| 205 | 422 | None => continue, | |
| 206 | 423 | } | |
| 207 | 424 | } else { | |
| 208 | − | row.value | |
| 425 | + | row.value.clone() | |
| 209 | 426 | }; | |
| 210 | − | out.insert(row.name, Value::String(value)); | |
| 427 | + | out.insert(name.to_owned(), Value::String(value)); | |
| 211 | 428 | } | |
| 212 | 429 | } | |
| 213 | 430 | Ok(out) | |
| 214 | 431 | } | |
| 215 | 432 | ||
| 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 | |
| 219 | 438 | } | |
| 220 | 439 | ||
| 221 | 440 | /// 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 | |
| 224 | 444 | } | |
| 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 | + | } | |
| 225 | 459 | } | |
| 226 | 460 | ||
| 227 | 461 | #[cfg(test)] | |
| 228 | 462 | mod tests { | |
| 229 | − | use super::valid_name; | |
| 463 | + | use super::{SettingRow, consumers, valid_environments, valid_name}; | |
| 230 | 464 | ||
| 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 | + | ||
| 231 | 481 | #[test] | |
| 232 | − | fn names_follow_githubs_rules() { | |
| 482 | + | fn names_follow_githubs_rules_and_keep_g1ts_own() { | |
| 233 | 483 | assert_eq!(valid_name("npm_token").unwrap(), "NPM_TOKEN"); | |
| 234 | 484 | assert!(valid_name("GITHUB_TOKEN").is_err()); | |
| 485 | + | assert!(valid_name("G1T_TOKEN").is_err()); | |
| 235 | 486 | assert!(valid_name("1PASSWORD").is_err()); | |
| 236 | 487 | assert!(valid_name("MY-TOKEN").is_err()); | |
| 237 | 488 | assert!(valid_name("").is_err()); | |
| 238 | 489 | } | |
| 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 | + | } | |
| 239 | 508 | } |
| 12 | 12 | production INTEGER NOT NULL DEFAULT 1, | |
| 13 | 13 | build_command TEXT, | |
| 14 | 14 | output_dir TEXT, | |
| 15 | − | -- A JSON object of variables the build runs with. | |
| 16 | − | build_env TEXT NOT NULL DEFAULT '{}', | |
| 17 | 15 | idle_days INTEGER NOT NULL DEFAULT 7, | |
| 18 | 16 | updated_by TEXT, | |
| 19 | 17 | updated_at TEXT NOT NULL | |
| 57 | 55 | log TEXT, | |
| 58 | 56 | -- SHA-256 of the token the sandbox reports with. | |
| 59 | 57 | 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, | |
| 60 | 62 | build_seconds INTEGER, | |
| 61 | 63 | created_by TEXT NOT NULL, | |
| 62 | 64 | created_at TEXT NOT NULL, |
| 89 | 89 | worker: BuiltWorker, | |
| 90 | 90 | completionJwt: string | null, | |
| 91 | 91 | 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: {} }, | |
| 92 | 94 | ): Promise<void> { | |
| 93 | 95 | const form = new FormData(); | |
| 94 | 96 | const modules = worker.modules?.length ? worker.modules : null; | |
| 95 | 97 | 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]) => | |
| 97 | 101 | typeof value === "string" | |
| 98 | 102 | ? { type: "plain_text", name, text: value } | |
| 99 | 103 | : { type: "json", name, json: value }, | |
| 100 | 104 | ); | |
| 105 | + | for (const [name, text] of Object.entries(runtime.secrets)) bindings.push({ type: "secret_text", name, text }); | |
| 101 | 106 | if (completionJwt) bindings.push({ type: "assets", name: assetsBinding }); | |
| 102 | 107 | const assetsConfig: Record<string, string> = {}; | |
| 103 | 108 | if (worker.htmlHandling && HTML_HANDLING.includes(worker.htmlHandling)) { |
| 53 | 53 | IDENTITY: ServiceBinding; | |
| 54 | 54 | BILLING: ServiceBinding; | |
| 55 | 55 | RUNNER: ServiceBinding; | |
| 56 | + | /** Secrets and variables: the actions service holds the one store. */ | |
| 57 | + | ACTIONS: ServiceBinding; | |
| 56 | 58 | /** Secret: scoped to Workers scripts and analytics on g1t's account. */ | |
| 57 | 59 | CLOUDFLARE_API_TOKEN?: string; | |
| 58 | 60 | CLOUDFLARE_ACCOUNT_ID: string; | |
| 63 | 65 | /** A build that has not reported in this long has died. */ | |
| 64 | 66 | const BUILD_TIMEOUT_MS = 45 * 60 * 1000; | |
| 65 | 67 | const LIST_LIMIT = 50; | |
| 66 | − | const MAX_ENV_VARS = 50; | |
| 67 | 68 | const STATUS_CONTEXT = "g1t / deploy"; | |
| 68 | 69 | ||
| 69 | 70 | const now = () => new Date().toISOString(); | |
| 91 | 92 | production: number; | |
| 92 | 93 | build_command: string | null; | |
| 93 | 94 | output_dir: string | null; | |
| 94 | − | build_env: string; | |
| 95 | 95 | idle_days: number; | |
| 96 | 96 | }; | |
| 97 | 97 | ||
| 109 | 109 | warnings: string; | |
| 110 | 110 | log: string | null; | |
| 111 | 111 | token_hash: string | null; | |
| 112 | + | trusted: number; | |
| 112 | 113 | build_seconds: number | null; | |
| 113 | 114 | created_by: string; | |
| 114 | 115 | created_at: string; | |
| 179 | 180 | return response.ok ? ((await response.json()) as RepoPath | null) : null; | |
| 180 | 181 | } | |
| 181 | 182 | ||
| 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 | + | ||
| 182 | 219 | private async settingsRow(repoId: string): Promise<SettingsRow | null> { | |
| 183 | 220 | return this.db.prepare("SELECT * FROM settings WHERE repo_id = ?").bind(repoId).first<SettingsRow>(); | |
| 184 | 221 | } | |
| 190 | 227 | production: row ? !!row.production : true, | |
| 191 | 228 | buildCommand: row?.build_command ?? null, | |
| 192 | 229 | outputDir: row?.output_dir ?? null, | |
| 193 | − | buildEnv: JSON.parse(row?.build_env ?? "{}") as Record<string, string>, | |
| 194 | 230 | idleDays: row?.idle_days ?? 7, | |
| 195 | 231 | productionUrl: appUrl(await scriptName(repo, null)), | |
| 196 | 232 | }; | |
| 226 | 262 | // Turning it on starts paid work: only with the workspace's plan. | |
| 227 | 263 | const plan = await billingClient(this.env.BILLING).hasFeature(a.repo.namespace, "deployments"); | |
| 228 | 264 | 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."); | |
| 234 | 265 | } | |
| 235 | 266 | const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7))); | |
| 236 | 267 | const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null); | |
| 237 | 268 | await this.db | |
| 238 | 269 | .prepare( | |
| 239 | 270 | `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) | |
| 242 | 273 | 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`, | |
| 244 | 275 | ) | |
| 245 | 276 | .bind( | |
| 246 | 277 | repo.value.id, | |
| 251 | 282 | next.production ? 1 : 0, | |
| 252 | 283 | clip(next.buildCommand), | |
| 253 | 284 | clip(next.outputDir), | |
| 254 | − | JSON.stringify(Object.fromEntries(env.map(([k, v]) => [k, String(v).slice(0, 2000)]))), | |
| 255 | 285 | idleDays, | |
| 256 | 286 | a.actor.username, | |
| 257 | 287 | now(), | |
| 371 | 401 | reader: User; | |
| 372 | 402 | createdBy: string; | |
| 373 | 403 | settings: SettingsRow; | |
| 404 | + | /** A push, or work by a member or an agent; see `trusted`. */ | |
| 405 | + | trusted: boolean; | |
| 374 | 406 | }): Promise<Result<Deployment>> { | |
| 375 | 407 | const script = await scriptName(input.repo, input.number); | |
| 376 | 408 | const id = newId("dpl"); | |
| 385 | 417 | await this.db | |
| 386 | 418 | .prepare( | |
| 387 | 419 | `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 390 | 422 | ) | |
| 391 | 423 | .bind( | |
| 392 | 424 | id, | |
| 400 | 432 | refused ? "skipped" : "queued", | |
| 401 | 433 | refused, | |
| 402 | 434 | refused ? null : await sha256(token), | |
| 435 | + | input.trusted ? 1 : 0, | |
| 403 | 436 | input.createdBy, | |
| 404 | 437 | now(), | |
| 405 | 438 | refused ? now() : null, | |
| 415 | 448 | .bind(now(), script, id) | |
| 416 | 449 | .run(); | |
| 417 | 450 | 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); | |
| 419 | 453 | const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", { | |
| 420 | 454 | method: "POST", | |
| 421 | 455 | headers: { "content-type": "application/json" }, | |
| 427 | 461 | commit: input.commit, | |
| 428 | 462 | buildCommand: input.settings.build_command, | |
| 429 | 463 | outputDir: input.settings.output_dir, | |
| 430 | − | buildEnv: env, | |
| 464 | + | buildEnv: build.variables, | |
| 465 | + | buildSecrets: build.secrets, | |
| 431 | 466 | }), | |
| 432 | 467 | }); | |
| 433 | 468 | const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`); | |
| 462 | 497 | reader: actor, | |
| 463 | 498 | createdBy, | |
| 464 | 499 | settings, | |
| 500 | + | // The default branch only moves by people and agents with access. | |
| 501 | + | trusted: true, | |
| 465 | 502 | }); | |
| 466 | 503 | } | |
| 467 | 504 | ||
| 502 | 539 | reader: pull.author, | |
| 503 | 540 | createdBy, | |
| 504 | 541 | settings, | |
| 542 | + | trusted: await this.insider(repo, pull.author, actor), | |
| 505 | 543 | }); | |
| 506 | 544 | } | |
| 507 | 545 | ||
| 541 | 579 | const worker = (body.worker ?? {}) as BuiltWorker; | |
| 542 | 580 | const seconds = Number(body.buildSeconds) || 0; | |
| 543 | 581 | 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); | |
| 544 | 585 | await cloudflare.putScript( | |
| 545 | 586 | row.script, | |
| 546 | 587 | worker, | |
| 547 | 588 | typeof body.completionJwt === "string" ? body.completionJwt : null, | |
| 548 | 589 | [`workspace:${row.namespace}`, `repo:${row.namespace}/${row.name}`, row.kind], | |
| 590 | + | runtime, | |
| 549 | 591 | ); | |
| 550 | 592 | } catch (error) { | |
| 551 | 593 | await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds); |
| 20 | 20 | { "binding": "WORK", "service": "g1t-work" }, | |
| 21 | 21 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 22 | 22 | { "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" } | |
| 24 | 26 | ], | |
| 25 | 27 | // Pull requests opened, pushed to, closed and merged; pushes to the | |
| 26 | 28 | // default branch. |
| 124 | 124 | commit: string; | |
| 125 | 125 | buildCommand?: string | null; | |
| 126 | 126 | outputDir?: string | null; | |
| 127 | + | /** The repository's variables for deploy builds. */ | |
| 127 | 128 | buildEnv?: Record<string, string>; | |
| 129 | + | /** Its secrets for deploy builds: set like variables, and redacted from the log. */ | |
| 130 | + | buildSecrets?: Record<string, string>; | |
| 128 | 131 | }; | |
| 129 | 132 | ||
| 130 | 133 | /** Long enough to install and build; then the read token stops working. */ | |
| 697 | 700 | BUILD_COMMAND: job.buildCommand ?? "", | |
| 698 | 701 | OUTPUT_DIR: job.outputDir ?? "", | |
| 699 | 702 | BUILD_ENV: JSON.stringify(job.buildEnv ?? {}), | |
| 703 | + | BUILD_SECRETS: JSON.stringify(job.buildSecrets ?? {}), | |
| 700 | 704 | }, | |
| 701 | 705 | }); | |
| 702 | 706 | } catch (error) { |