Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Environment settings: deployment branches and environment secrets in the web UI #308

Mergedtest-botagenton behalf of cnbrkdmrcimerged into main from claude/environment-ui
4 files changed+197−11base 629b6ab → head 0ad2478
Files (4)
modifiedapps/diffinery/docs/actions/environments.md+8−4View file
@@ -25,7 +25,7 @@
2525 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.
2626 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.
2727
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.
2929
3030 ## Create an environment
3131
@@ -129,6 +129,8 @@
129129
130130 Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`.
131131
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+
132134 The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it.
133135
134136 ### Protected branches only
@@ -280,12 +282,14 @@
280282
281283 ## Environment secrets and variables
282284
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.
284286
285287 ### Environment secrets
286288
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.
288290
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+
289293 ```sh
290294 # Create or replace a secret: 201 Created when new, 204 No Content when replaced
291295 curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
@@ -872,7 +876,7 @@
872876
873877 | Area | GitHub | Diffinery |
874878 |---|---|---|
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 |
876880 | Required reviewers | People and teams | People only, never agents; an agent or a job token can't approve |
877881 | Prevent self-review | Covers whoever triggered the run | Also covers earlier attempts' starters and the owner of an agent that started it |
878882 | 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 @@
1313 | 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) |
1414 | Repository variable | One repository | Yes | Yes | People with the admin role on the repository |
1515 | 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 |
1717 | Environment variable | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository |
1818
1919 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 @@
226226 DEPLOY_URL: ${{ vars.DEPLOY_URL }} # the production variable
227227 ```
228228
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.
230230
231231 ```sh
232232 # Environment secret: 201 when new, 204 when replaced
modifiedapps/diffinery/src/web/repo-settings.tsx+154−5View file
@@ -656,9 +656,19 @@
656656 );
657657 }
658658
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+
659669 async function environmentPage(c: AppContext, l: Loaded, name: string) {
660670 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)]);
662672 const p = e.protection;
663673 return page(
664674 c,
@@ -692,14 +702,89 @@
692702 <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." />
693703 </div>
694704 <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} />
697706 </div>
698707 <div class="factions">
699708 <button class="btn primary">Save protection rules</button>
700709 </div>
701710 </div>
702711 </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>
703788 <Form action={`${envPath(l, e.name)}/delete`} t={t} class="box" confirm={`Delete the ${e.name} environment, its secrets and its variables?`}>
704789 <div class="body drow">
705790 <div>
@@ -930,13 +1015,77 @@
9301015 const reviewers = await resolveReviewers(c, l.repo, logins.map((login) => ({ type: "User", login })));
9311016 const raw = f.s("wait_timer").trim();
9321017 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+ });
9351029 },
9361030 "Protection rules saved.",
9371031 );
9381032 });
9391033
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+
9401089 post(web, "/:owner/:repo/settings/environments/:env/delete", async (c) => {
9411090 const l = await load(c, WHILE_IMPORTING);
9421091 const name = prm(c, "env");
modifiedapps/diffinery/test/e2e/e2e.test.mjs+33−0View file
@@ -2299,6 +2299,39 @@
22992299 assert.ok((await gh.rest.repos.getAllEnvironments(R)).data.environments.some((e) => e.name === "staging"));
23002300 await gh.rest.actions.createEnvironmentVariable({ ...R, environment_name: "staging", name: "REGION", value: "eu" });
23012301 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(), []);
23022335 await gh.rest.repos.deleteAnEnvironment({ ...R, environment_name: "staging" });
23032336
23042337 // ------------------------------------------------------------ repository_dispatch and repository variables