Environment settings: deployment branches and environment secrets in the web UI (#308)
* Environment page: deployment branches picker, patterns and environment secrets
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)Failed
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
- Mirror to GitHub / Push main to the GitHub mirrorsuccess
4 files changed+197−11
Files (4)
modifiedapps/diffinery/docs/actions/environments.md+8−4View file
| @@ -25,7 +25,7 @@ | ||
| 25 | 25 | 3. When the job starts on `main` (a protected branch), Diffinery records a deployment with an `in_progress` status, and the job gets the `production` secrets and variables. |
| 26 | 26 | 4. When the job finishes, the deployment gets a final status (`success`, `failure` or `inactive`). Webhooks receive a `deployment` event when the deployment is created and `deployment_status` events for each status. |
| 27 | 27 | |
| 28 | -Repository admins manage environments under **Settings → Environments** (protection rules) and with the REST API, which follows GitHub's paths and JSON shapes, so `gh api`, Octokit and existing scripts work after you point them at `https://diffinery.com/api/v3`. Branch patterns, environment secrets and variables are managed with the API. | |
| 28 | +Repository admins manage environments under **Settings → Environments** (protection rules, deployment branches and their patterns, and environment secrets) and with the REST API, which follows GitHub's paths and JSON shapes, so `gh api`, Octokit and existing scripts work after you point them at `https://diffinery.com/api/v3`. Environment variables are managed with the API. | |
| 29 | 29 | |
| 30 | 30 | ## Create an environment |
| 31 | 31 | |
| @@ -129,6 +129,8 @@ | ||
| 129 | 129 | |
| 130 | 130 | Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`. |
| 131 | 131 | |
| 132 | +In the web UI, open **Settings → Environments → (environment)**, choose **No restriction**, **Protected branches only** or **Selected branches and tags** under **Deployment branches**, and select **Save protection rules**. With **Selected branches and tags** saved, a **Branch and tag patterns** box appears below, where you add a pattern (choose whether it matches branches or tags) and remove one. | |
| 133 | + | |
| 132 | 134 | The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it. |
| 133 | 135 | |
| 134 | 136 | ### Protected branches only |
| @@ -280,12 +282,14 @@ | ||
| 280 | 282 | |
| 281 | 283 | ## Environment secrets and variables |
| 282 | 284 | |
| 283 | -Environment secrets and variables work like repository ones, but only jobs that use the environment receive them. They are managed with the REST API. Secret values are encrypted at rest with a key specific to the repository and the environment. | |
| 285 | +Environment secrets and variables work like repository ones, but only jobs that use the environment receive them. Secrets are managed under **Settings → Environments → (environment) → Environment secrets** or with the REST API; variables with the REST API. Secret values are encrypted at rest with a key specific to the repository and the environment. | |
| 284 | 286 | |
| 285 | 287 | ### Environment secrets |
| 286 | 288 | |
| 287 | -Setting and deleting environment secrets needs a person (not an agent) with the admin role on the repository, and a token with the `admin` scope. The environment must exist. | |
| 289 | +Setting and deleting environment secrets needs a person (not an agent) with the admin role on the repository, and with the API a token with the `admin` scope. The environment must exist. | |
| 288 | 290 | |
| 291 | +In the web UI, the **Environment secrets** box on the environment's page lists the secret names with when each was last updated. Enter a name and a value and select **Save secret** to add one or replace its value, or select **Delete** next to a name. Values are never shown again after you save them. | |
| 292 | + | |
| 289 | 293 | ```sh |
| 290 | 294 | # Create or replace a secret: 201 Created when new, 204 No Content when replaced |
| 291 | 295 | curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ |
| @@ -872,7 +876,7 @@ | ||
| 872 | 876 | |
| 873 | 877 | | Area | GitHub | Diffinery | |
| 874 | 878 | |---|---|---| |
| 875 | -| Managing environments | Web UI and API | Protection rules in Settings → Environments and the API; patterns, secrets and variables with the API | | |
| 879 | +| Managing environments | Web UI and API | Protection rules, deployment branches, patterns and secrets in Settings → Environments and the API; variables with the API | | |
| 876 | 880 | | Required reviewers | People and teams | People only, never agents; an agent or a job token can't approve | |
| 877 | 881 | | Prevent self-review | Covers whoever triggered the run | Also covers earlier attempts' starters and the owner of an agent that started it | |
| 878 | 882 | | Admin bypass | Admins can bypass protection rules | Nobody bypasses them; `can_admins_bypass` is `false` | |
modifiedapps/diffinery/docs/actions/secrets-and-variables.md+2−2View file
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | | Organization secret | Every repository the organization owns, its private ones, or the ones you select | No | Yes | Organization owners and admins, and site admins (people only) | |
| 14 | 14 | | Repository variable | One repository | Yes | Yes | People with the admin role on the repository | |
| 15 | 15 | | Organization variable | Every repository the organization owns | No | API only | Organization owners and admins, and site admins (people only) | |
| 16 | -| Environment secret | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository | | |
| 16 | +| Environment secret | Jobs of one repository that use the environment | Yes | API only | People with the admin role on the repository | | |
| 17 | 17 | | Environment variable | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository | |
| 18 | 18 | |
| 19 | 19 | Agents can't create, change or delete secrets or variables anywhere, whatever their role (see [Agents, secrets and variables](#agents-secrets-and-variables)). Environment secrets and variables are described in [Environment secrets and variables](#environment-secrets-and-variables) below and in full in [Deployments and environments](/docs/actions/environments). |
| @@ -226,7 +226,7 @@ | ||
| 226 | 226 | DEPLOY_URL: ${{ vars.DEPLOY_URL }} # the production variable |
| 227 | 227 | ``` |
| 228 | 228 | |
| 229 | -They're managed with the REST API only. Creating and deleting environment secrets needs a person (not an agent) with the admin role and an `admin`-scoped token; listing their names needs the admin role. Environment variables are listed with the write role and changed by a person (not an agent) with the admin role. | |
| 229 | +Environment secrets are managed on the environment's page (**Settings → Environments → (environment) → Environment secrets**) or with the REST API, and environment variables with the REST API only. Creating and deleting environment secrets needs a person (not an agent) with the admin role and an `admin`-scoped token; listing their names needs the admin role. Environment variables are listed with the write role and changed by a person (not an agent) with the admin role. | |
| 230 | 230 | |
| 231 | 231 | ```sh |
| 232 | 232 | # Environment secret: 201 when new, 204 when replaced |
modifiedapps/diffinery/src/web/repo-settings.tsx+154−5View file
| @@ -656,9 +656,19 @@ | ||
| 656 | 656 | ); |
| 657 | 657 | } |
| 658 | 658 | |
| 659 | +type BranchPolicyChoice = "all" | "protected" | "selected"; | |
| 660 | + | |
| 661 | +const BRANCH_POLICY_ITEMS = [ | |
| 662 | + { value: "all", label: "No restriction", description: "Any branch or tag can deploy." }, | |
| 663 | + { value: "protected", label: "Protected branches only", description: "Branches a branch protection rule matches. The only setting under which runs whose code came from an agent receive this environment's secrets." }, | |
| 664 | + { value: "selected", label: "Selected branches and tags", description: "Only branches and tags that match the patterns you add below, after saving." }, | |
| 665 | +]; | |
| 666 | + | |
| 667 | +const branchPolicyOf = (e: Environment): BranchPolicyChoice => (!e.policy ? "all" : e.policy.protected_branches ? "protected" : "selected"); | |
| 668 | + | |
| 659 | 669 | async function environmentPage(c: AppContext, l: Loaded, name: string) { |
| 660 | 670 | const t = csrfToken(c); |
| 661 | - const e = await l.stub.environment(name); | |
| 671 | + const [e, secrets] = await Promise.all([l.stub.environment(name), l.stub.environmentSecretNames(name)]); | |
| 662 | 672 | const p = e.protection; |
| 663 | 673 | return page( |
| 664 | 674 | c, |
| @@ -692,14 +702,89 @@ | ||
| 692 | 702 | <KCheckbox name="prevent_self_review" checked={p.preventSelfReview} label="Prevent self-review" description="Whoever started a run (or re-ran it, or owns the agent that did) can't approve it." /> |
| 693 | 703 | </div> |
| 694 | 704 | <div class="field"> |
| 695 | - <label>Deployment branches</label> | |
| 696 | - <div class="muted small">{e.policy ? (e.policy.protected_branches ? "Protected branches only." : `Only refs matching: ${e.policy.patterns.map((x) => `${x.type} ${x.name}`).join(", ") || "(none yet)"}.`) : "Any branch or tag."} Change it with the REST API (deployment branch policies).</div> | |
| 705 | + <KRadio name="branch_policy" label="Deployment branches" value={branchPolicyOf(e)} items={BRANCH_POLICY_ITEMS} /> | |
| 697 | 706 | </div> |
| 698 | 707 | <div class="factions"> |
| 699 | 708 | <button class="btn primary">Save protection rules</button> |
| 700 | 709 | </div> |
| 701 | 710 | </div> |
| 702 | 711 | </Form> |
| 712 | + {e.policy?.custom_branch_policies ? ( | |
| 713 | + <div class="box"> | |
| 714 | + <div class="head"> | |
| 715 | + <h2>Branch and tag patterns</h2> | |
| 716 | + </div> | |
| 717 | + {e.policy.patterns.length ? ( | |
| 718 | + <ul class="list"> | |
| 719 | + {e.policy.patterns.map((x) => ( | |
| 720 | + <li style="align-items:center"> | |
| 721 | + <span class="mono grow"> | |
| 722 | + <b>{x.name}</b> | |
| 723 | + </span> | |
| 724 | + <span class="muted small">{x.type}</span> | |
| 725 | + <Form action={`${envPath(l, e.name)}/branch-policies/delete`} t={t} class="inline" confirm={`Remove the ${x.type} pattern ${x.name}?`}> | |
| 726 | + <input type="hidden" name="id" value={String(x.id)} /> | |
| 727 | + <button class="btn sm danger">Remove</button> | |
| 728 | + </Form> | |
| 729 | + </li> | |
| 730 | + ))} | |
| 731 | + </ul> | |
| 732 | + ) : ( | |
| 733 | + <div class="empty">No patterns yet, so no branch or tag can deploy here.</div> | |
| 734 | + )} | |
| 735 | + <Form action={`${envPath(l, e.name)}/branch-policies`} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)"> | |
| 736 | + <div class="field m0" style="min-width:240px"> | |
| 737 | + <label for="env-pattern">Pattern</label> | |
| 738 | + <input id="env-pattern" name="name" type="text" required maxlength={255} class="mono" placeholder="release/*" autocomplete="off" /> | |
| 739 | + </div> | |
| 740 | + <div class="field m0"> | |
| 741 | + <KSelect name="type" label="Matches" value="branch" options={[{ value: "branch", label: "Branches" }, { value: "tag", label: "Tags" }]} /> | |
| 742 | + </div> | |
| 743 | + <button class="btn">Add pattern</button> | |
| 744 | + </Form> | |
| 745 | + </div> | |
| 746 | + ) : null} | |
| 747 | + <div class="box"> | |
| 748 | + <div class="head"> | |
| 749 | + <h2>{raw(ICONS.key)} Environment secrets</h2> | |
| 750 | + </div> | |
| 751 | + {secrets.length ? ( | |
| 752 | + <ul class="list"> | |
| 753 | + {secrets.map((s) => ( | |
| 754 | + <li style="align-items:center"> | |
| 755 | + <span class="mono grow"> | |
| 756 | + <b>{s.name}</b> | |
| 757 | + </span> | |
| 758 | + <span class="muted small">updated {ago(s.updatedAt)}</span> | |
| 759 | + <Form action={`${envPath(l, e.name)}/secrets/delete`} t={t} class="inline" confirm={`Delete secret ${s.name}?`}> | |
| 760 | + <input type="hidden" name="name" value={s.name} /> | |
| 761 | + <button class="btn sm danger">Delete</button> | |
| 762 | + </Form> | |
| 763 | + </li> | |
| 764 | + ))} | |
| 765 | + </ul> | |
| 766 | + ) : ( | |
| 767 | + <div class="empty">No environment secrets yet.</div> | |
| 768 | + )} | |
| 769 | + <Form action={`${envPath(l, e.name)}/secrets`} t={t} class="body fbody" style="border-top:1px solid var(--border2)"> | |
| 770 | + <div class="fgrid"> | |
| 771 | + <div class="field"> | |
| 772 | + <label for="env-secret-name">Name</label> | |
| 773 | + <input id="env-secret-name" name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" placeholder="DEPLOY_TOKEN" autocomplete="off" /> | |
| 774 | + </div> | |
| 775 | + <div class="field full"> | |
| 776 | + <label for="env-secret-value">Value</label> | |
| 777 | + <textarea id="env-secret-value" name="value" required class="mono" rows={4} autocomplete="off" spellcheck={false}></textarea> | |
| 778 | + <div class="hint"> | |
| 779 | + Only jobs with <code>environment: {e.name}</code> receive it, as <code>{"${{ secrets.NAME }}"}</code>. Setting an existing name replaces its value.{e.policy ? "" : " Adding the first secret limits deployment branches to protected branches only."} | |
| 780 | + </div> | |
| 781 | + </div> | |
| 782 | + </div> | |
| 783 | + <div class="factions"> | |
| 784 | + <button class="btn primary">Save secret</button> | |
| 785 | + </div> | |
| 786 | + </Form> | |
| 787 | + </div> | |
| 703 | 788 | <Form action={`${envPath(l, e.name)}/delete`} t={t} class="box" confirm={`Delete the ${e.name} environment, its secrets and its variables?`}> |
| 704 | 789 | <div class="body drow"> |
| 705 | 790 | <div> |
| @@ -930,13 +1015,77 @@ | ||
| 930 | 1015 | const reviewers = await resolveReviewers(c, l.repo, logins.map((login) => ({ type: "User", login }))); |
| 931 | 1016 | const raw = f.s("wait_timer").trim(); |
| 932 | 1017 | const waitTimer = raw === "" ? 0 : Number(raw); |
| 933 | - const e = await l.stub.upsertEnvironment(name, { reviewers, wait_timer: waitTimer, prevent_self_review: f.s("prevent_self_review") === "1" }, l.caller); | |
| 934 | - audit(c, l, "environment.update", { environment: e.name, reviewers: e.protection.reviewers.map((r) => r.login), wait_timer: e.protection.waitTimer, prevent_self_review: e.protection.preventSelfReview }); | |
| 1018 | + const choice = f.keys().includes("branch_policy") ? f.s("branch_policy") : undefined; | |
| 1019 | + if (choice !== undefined && !["all", "protected", "selected"].includes(choice)) throw new HttpError(422, "choose which branches can deploy"); | |
| 1020 | + const deployment_branch_policy = choice === undefined ? undefined : choice === "all" ? null : { protected_branches: choice === "protected", custom_branch_policies: choice === "selected" }; | |
| 1021 | + const e = await l.stub.upsertEnvironment(name, { reviewers, wait_timer: waitTimer, prevent_self_review: f.s("prevent_self_review") === "1", deployment_branch_policy }, l.caller); | |
| 1022 | + audit(c, l, "environment.update", { | |
| 1023 | + environment: e.name, | |
| 1024 | + reviewers: e.protection.reviewers.map((r) => r.login), | |
| 1025 | + wait_timer: e.protection.waitTimer, | |
| 1026 | + prevent_self_review: e.protection.preventSelfReview, | |
| 1027 | + deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null, | |
| 1028 | + }); | |
| 935 | 1029 | }, |
| 936 | 1030 | "Protection rules saved.", |
| 937 | 1031 | ); |
| 938 | 1032 | }); |
| 939 | 1033 | |
| 1034 | + post(web, "/:owner/:repo/settings/environments/:env/branch-policies", async (c, f) => { | |
| 1035 | + const l = await load(c, WHILE_IMPORTING); | |
| 1036 | + const name = prm(c, "env"); | |
| 1037 | + return act( | |
| 1038 | + c, | |
| 1039 | + envPath(l, name), | |
| 1040 | + async () => { | |
| 1041 | + const p = await l.stub.addEnvironmentBranchPattern(name, { name: f.s("name"), type: f.s("type") }, l.caller); | |
| 1042 | + audit(c, l, "environment.branch_policy.create", { environment: name, pattern: p.name, type: p.type }); | |
| 1043 | + }, | |
| 1044 | + "Pattern added.", | |
| 1045 | + ); | |
| 1046 | + }); | |
| 1047 | + | |
| 1048 | + post(web, "/:owner/:repo/settings/environments/:env/branch-policies/delete", async (c, f) => { | |
| 1049 | + const l = await load(c, WHILE_IMPORTING); | |
| 1050 | + const name = prm(c, "env"); | |
| 1051 | + return act(c, envPath(l, name), async () => { | |
| 1052 | + const id = Number(f.s("id")); | |
| 1053 | + if (!Number.isInteger(id)) throw new HttpError(422, "invalid pattern"); | |
| 1054 | + await l.stub.removeEnvironmentBranchPattern(name, id, l.caller); | |
| 1055 | + audit(c, l, "environment.branch_policy.delete", { environment: name, id }); | |
| 1056 | + }); | |
| 1057 | + }); | |
| 1058 | + | |
| 1059 | + post(web, "/:owner/:repo/settings/environments/:env/secrets", async (c, f) => { | |
| 1060 | + const l = await load(c, WHILE_IMPORTING); | |
| 1061 | + const env = prm(c, "env"); | |
| 1062 | + return act( | |
| 1063 | + c, | |
| 1064 | + envPath(l, env), | |
| 1065 | + async () => { | |
| 1066 | + const name = f.s("name").trim(); | |
| 1067 | + try { | |
| 1068 | + await l.stub.setEnvironmentSecret(env, name, f.s("value"), l.caller); | |
| 1069 | + } catch (e) { | |
| 1070 | + const s = statusOf(e); | |
| 1071 | + if (s.status < 500) throw e; | |
| 1072 | + throw new HttpError(422, `could not store the secret: ${(e as Error).message || "encryption unavailable"} (is MASTER_KEY configured?)`); | |
| 1073 | + } | |
| 1074 | + audit(c, l, "environment.secret.set", { environment: env, name: name.toUpperCase() }); | |
| 1075 | + }, | |
| 1076 | + "Secret saved.", | |
| 1077 | + ); | |
| 1078 | + }); | |
| 1079 | + | |
| 1080 | + post(web, "/:owner/:repo/settings/environments/:env/secrets/delete", async (c, f) => { | |
| 1081 | + const l = await load(c, WHILE_IMPORTING); | |
| 1082 | + const env = prm(c, "env"); | |
| 1083 | + return act(c, envPath(l, env), async () => { | |
| 1084 | + await l.stub.deleteEnvironmentSecret(env, f.s("name"), l.caller); | |
| 1085 | + audit(c, l, "environment.secret.delete", { environment: env, name: f.s("name").toUpperCase() }); | |
| 1086 | + }); | |
| 1087 | + }); | |
| 1088 | + | |
| 940 | 1089 | post(web, "/:owner/:repo/settings/environments/:env/delete", async (c) => { |
| 941 | 1090 | const l = await load(c, WHILE_IMPORTING); |
| 942 | 1091 | const name = prm(c, "env"); |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+33−0View file
| @@ -2299,6 +2299,39 @@ | ||
| 2299 | 2299 | assert.ok((await gh.rest.repos.getAllEnvironments(R)).data.environments.some((e) => e.name === "staging")); |
| 2300 | 2300 | await gh.rest.actions.createEnvironmentVariable({ ...R, environment_name: "staging", name: "REGION", value: "eu" }); |
| 2301 | 2301 | assert.equal((await gh.rest.actions.getEnvironmentVariable({ ...R, environment_name: "staging", name: "REGION" })).data.value, "eu"); |
| 2302 | + // The environment page sets deployment branches, their patterns and the environment's secrets. | |
| 2303 | + const envUrl = `${base}/${owner}/${repo}/settings/environments/staging`; | |
| 2304 | + const envForm = async (path, fields) => { | |
| 2305 | + const pageRes = await fetch(envUrl); | |
| 2306 | + const html = await pageRes.text(); | |
| 2307 | + const r = await fetch(`${envUrl}${path}`, { | |
| 2308 | + method: "POST", | |
| 2309 | + body: new URLSearchParams({ _csrf: /name="_csrf" value="([^"]+)"/.exec(html)[1], ...fields }), | |
| 2310 | + redirect: "manual", | |
| 2311 | + headers: { cookie: `dfy_csrf=${/dfy_csrf=([^;]+)/.exec(pageRes.headers.get("set-cookie") ?? "")[1]}`, "sec-fetch-site": "same-origin" }, | |
| 2312 | + }); | |
| 2313 | + assert.equal(r.status, 303, await r.text()); | |
| 2314 | + return html; | |
| 2315 | + }; | |
| 2316 | + const envState = async () => (await gh.request("GET /repos/{owner}/{repo}/environments/{environment_name}", { ...R, environment_name: "staging" })).data.deployment_branch_policy; | |
| 2317 | + const envSecrets = async () => (await gh.request("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets", { ...R, environment_name: "staging" })).data.secrets.map((x) => x.name); | |
| 2318 | + assert.match(await envForm("/secrets", { name: "deploy_key", value: "env-s3cret" }), /<input type="hidden" name="branch_policy" value="all"\/>/); | |
| 2319 | + assert.deepEqual(await envSecrets(), ["DEPLOY_KEY"]); | |
| 2320 | + assert.deepEqual(await envState(), { protected_branches: true, custom_branch_policies: false }, "the first secret limits the environment to protected branches"); | |
| 2321 | + await envForm("", { reviewers: "", wait_timer: "0", branch_policy: "selected" }); | |
| 2322 | + assert.deepEqual(await envState(), { protected_branches: false, custom_branch_policies: true }); | |
| 2323 | + await envForm("/branch-policies", { name: "release/*", type: "tag" }); | |
| 2324 | + const patterns = (await gh.request("GET /repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies", { ...R, environment_name: "staging" })).data.branch_policies; | |
| 2325 | + assert.deepEqual(patterns.map((x) => [x.name, x.type]), [["release/*", "tag"]]); | |
| 2326 | + const envHtml = await envForm("/branch-policies/delete", { id: String(patterns[0].id) }); | |
| 2327 | + assert.match(envHtml, /release\/\*/); | |
| 2328 | + assert.match(envHtml, /DEPLOY_KEY/); | |
| 2329 | + assert.doesNotMatch(envHtml, /env-s3cret/); | |
| 2330 | + assert.equal((await gh.request("GET /repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies", { ...R, environment_name: "staging" })).data.total_count, 0); | |
| 2331 | + await envForm("", { reviewers: "", wait_timer: "0", branch_policy: "all" }); | |
| 2332 | + assert.equal(await envState(), null); | |
| 2333 | + await envForm("/secrets/delete", { name: "DEPLOY_KEY" }); | |
| 2334 | + assert.deepEqual(await envSecrets(), []); | |
| 2302 | 2335 | await gh.rest.repos.deleteAnEnvironment({ ...R, environment_name: "staging" }); |
| 2303 | 2336 | |
| 2304 | 2337 | // ------------------------------------------------------------ repository_dispatch and repository variables |