Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
33 files+1545−4210/33 viewed
| 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) { |