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.

812 lines · 45.4 KBRaw History
1---
2title: Deployments and environments
3description: Create deployment environments, limit which branches and tags deploy to them, give them their own secrets and variables, and track deployments.
4---
5
6An environment is a named deployment target, such as `staging` or `production`, with its own secrets, variables and a policy that decides which branches and tags may deploy to it. A workflow job that names an environment gets that environment's secrets and variables, is refused if its ref isn't allowed, and records a deployment with statuses that other tools can follow. Diffinery implements GitHub's environments, deployments and deployment statuses, including their REST API and webhooks; the differences are listed in [Differences from GitHub](#differences-from-github).
7
8## How it fits together
9
10| Concept | What it is |
11|---|---|
12| Environment | A name in one repository (`production`), with an optional deployment branch policy, secrets and variables. |
13| Deployment branch policy | Which refs may use the environment: any ref (no policy), protected branches only, or a list of branch and tag patterns. |
14| Environment secret | An encrypted value that only jobs using the environment receive, and only when the rules in [Who receives environment secrets](#who-receives-environment-secrets) allow it. |
15| Environment variable | A plain value that jobs using the environment receive in the `vars` context. |
16| Deployment | A record that a commit was (or is being) deployed to an environment. Workflow jobs create one automatically; you can also create them with the REST API. |
17| Deployment status | The state of a deployment over time: `queued`, `pending`, `in_progress`, `success`, `failure`, `error` or `inactive`, with optional log and environment URLs. |
18
19A typical setup:
20
211. A repository admin creates `production`, limits it to protected branches, and stores the deploy credentials as environment secrets.
222. The deploy job in a workflow says `environment: production`.
233. 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.
244. 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.
25
26There's no web page for environments or deployments yet. You manage them 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`.
27
28## Create an environment
29
30An environment is created in one of three ways:
31
32- **Explicitly**, with `PUT /api/v3/repos/{owner}/{repo}/environments/{name}`. This needs a person with the admin role on the repository and a token with the `admin` scope.
33- **By a workflow job** that names an environment that doesn't exist yet. The job creates it with no branch policy, secrets or variables, and the job runs.
34- **By creating a deployment** with the REST API for an environment that doesn't exist yet.
35
36Environments created automatically have no branch policy, so every ref may use them. Create sensitive environments yourself, with a policy, before a workflow uses them.
37
38### Environment names
39
40- 1 to 255 characters, with no control characters, `/` or `\`. Anything else fails with `422 invalid environment name`.
41- Names are case-insensitive: `Production` and `production` are the same environment. Once an environment exists, deployments, statuses and jobs that name it in another case use its existing spelling, and filtering deployments with `?environment=` ignores case.
42- URL-encode the name in API paths, for example `environments/QA%20cluster`.
43
44### Create one with the API
45
46An empty body creates the environment with no branch policy:
47
48```sh
49curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/staging \
50 -H "Authorization: Bearer dfy_..."
51```
52
53Create one that only protected branches may use:
54
55```sh
56curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \
57 -H "Authorization: Bearer dfy_..." \
58 -H "Content-Type: application/json" \
59 -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}'
60```
61
62The response (`200 OK`) is the environment:
63
64```json
65{
66 "id": 1,
67 "node_id": "RW52aXJvbm1lbnQ6YWNtZS93ZWJzaXRlLzE",
68 "name": "production",
69 "url": "https://diffinery.com/api/v3/repos/acme/website/environments/production",
70 "html_url": "https://diffinery.com/acme/website/actions",
71 "created_at": "2026-10-13T09:00:00Z",
72 "updated_at": "2026-10-13T09:00:00Z",
73 "can_admins_bypass": true,
74 "protection_rules": [{ "id": 1, "node_id": "QnJhbmNoUG9saWN5OjE", "type": "branch_policy" }],
75 "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
76}
77```
78
79Notes on the fields:
80
81- `protection_rules` holds one `branch_policy` entry when the environment has a branch policy, and is empty otherwise. Required reviewers and wait timers don't exist (see [What isn't supported](#what-isnt-supported)).
82- `can_admins_bypass` is always `true` for compatibility, but nobody bypasses the branch policy: it applies to every run, whoever started it.
83- `html_url` points to the repository's Actions page. Diffinery has no web page for environments yet; deployments made by workflow jobs show on the runs that made them.
84
85`PUT` on an existing environment updates it. Fields you leave out keep their values. The body accepts:
86
87| Field | Effect |
88|---|---|
89| `deployment_branch_policy` | `null` removes the policy (any ref may deploy). An object sets exactly one of `protected_branches` and `custom_branch_policies` to `true`. Leave the field out to keep the current policy. |
90| `wait_timer` | Must be `0` or absent. A positive value fails with `422 required reviewers and wait timers are not supported yet`. |
91| `reviewers` | Must be empty, `null` or absent. A non-empty list fails with the same error. |
92| `prevent_self_review` | Accepted and ignored. |
93
94### List, get and delete environments
95
96```sh
97# Every environment, sorted by name (read access)
98curl https://diffinery.com/api/v3/repos/acme/website/environments \
99 -H "Authorization: Bearer dfy_..."
100
101# One environment (read access)
102curl https://diffinery.com/api/v3/repos/acme/website/environments/production \
103 -H "Authorization: Bearer dfy_..."
104
105# Delete it, with its secrets and variables (admin, people only)
106curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/staging \
107 -H "Authorization: Bearer dfy_..."
108```
109
110The list returns `{"total_count": 2, "environments": [...]}` and takes `per_page` (default 30, at most 100) and `page`. Deleting returns `204 No Content` and removes the environment's secrets and variables. The deployments recorded for it stay. A missing environment returns `404 environment not found`.
111
112## Deployment branch policies
113
114A branch policy decides which refs a workflow job may use the environment from. It's checked when a job with `environment:` starts. A job whose ref isn't allowed fails before any step runs, with:
115
116```text
117feature/login is not allowed to deploy to production due to environment protection rules.
118```
119
120The policy has three settings:
121
122| Setting | API value | Which refs may deploy |
123|---|---|---|
124| No policy | `"deployment_branch_policy": null` | Every branch, tag and pull request ref. |
125| Protected branches only | `{"protected_branches": true, "custom_branch_policies": false}` | Branches that a branch protection rule matches. Tags and pull request refs never. |
126| Selected branches and tags | `{"protected_branches": false, "custom_branch_policies": true}` | Branches and tags that match one of the environment's patterns. Pull request refs never. |
127
128Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`.
129
130The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it.
131
132### Protected branches only
133
134A branch counts as protected when one of the repository's branch protection rules matches its name (see [Branch protection](/docs/repositories/branch-protection)). The default branch is only protected for this purpose when a rule matches it, so add a rule for `main` if production deploys from `main`.
135
136This is the setting to use for environments whose secrets matter: changes reach protected branches through reviewed pull requests, and it's the only setting under which runs whose code came from an agent receive environment secrets.
137
138### Selected branches and tags
139
140With `custom_branch_policies`, the environment allows the refs that match its patterns, and nothing else. A new custom policy has no patterns, so no ref may deploy until you add one.
141
142Add a pattern (admin, people only):
143
144```sh
145curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \
146 -H "Authorization: Bearer dfy_..." \
147 -H "Content-Type: application/json" \
148 -d '{"name": "release/*", "type": "branch"}'
149```
150
151```json
152{ "id": 1, "name": "release/*", "type": "branch", "node_id": "QnJhbmNoUG9saWN5OjEvMQ" }
153```
154
155`type` is `branch` (the default) or `tag`. A branch pattern is matched against branch names (`refs/heads/<name>`), and a tag pattern against tag names (`refs/tags/<name>`). Adding a pattern to an environment that doesn't use `custom_branch_policies` fails with `422 enable custom_branch_policies on the environment first`; an empty `name` fails with `422 name is required`.
156
157List and delete patterns:
158
159```sh
160curl https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \
161 -H "Authorization: Bearer dfy_..."
162
163curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies/1 \
164 -H "Authorization: Bearer dfy_..."
165```
166
167The list returns `{"total_count": 1, "branch_policies": [{"id": 1, "name": "release/*", "type": "branch", "node_id": "..."}]}`. Switching an environment from custom patterns to protected branches only clears its patterns; switching back to custom patterns starts empty.
168
169Pattern syntax:
170
171| Pattern | Matches |
172|---|---|
173| `*` | Any characters except `/` |
174| `**` | Any characters, including `/` |
175| `?` | One character other than `/` |
176| Anything else | Itself, case-sensitively |
177
178Examples:
179
180| Pattern | Type | Matches | Doesn't match |
181|---|---|---|---|
182| `main` | branch | `main` | `main-2`, `Main` |
183| `release/*` | branch | `release/1.4` | `release/1.4/hotfix`, `release` |
184| `release/**` | branch | `release/1.4`, `release/1.4/hotfix` | `releases/1.4` |
185| `v*` | tag | `v1.4.0`, `v2` | `1.4.0` |
186| `v?.*` | tag | `v1.4`, `v2.0` | `v10.0` |
187
188> **Important:** A tag pattern never matches a tag that an agent created or last moved, whether it pushed the tag with git, created it with a release or wrote it through the API. Diffinery remembers who last moved each tag. Tags that people push can deploy through a tag pattern; tags that agents push can't.
189
190### The default policy when the first secret is added
191
192When you add a secret to an environment that has no branch policy, Diffinery sets the policy to **protected branches only** at the same time. This keeps a new environment's secrets away from unreviewed branches. If you want selected branches and tags instead, set `custom_branch_policies` (before or after adding the secret) and add patterns. If you remove the policy afterwards with `"deployment_branch_policy": null`, it stays removed.
193
194## Environment secrets and variables
195
196Environment 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.
197
198### Environment secrets
199
200Setting 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.
201
202```sh
203# Create or replace a secret: 201 Created when new, 204 No Content when replaced
204curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
205 -H "Authorization: Bearer dfy_..." \
206 -H "Content-Type: application/json" \
207 -d '{"value": "cf_..."}'
208
209# List names (values are never returned)
210curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets \
211 -H "Authorization: Bearer dfy_..."
212
213# One secret's metadata
214curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
215 -H "Authorization: Bearer dfy_..."
216
217# Delete (204 No Content)
218curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
219 -H "Authorization: Bearer dfy_..."
220```
221
222The list returns `{"total_count": 1, "secrets": [{"name": "CLOUDFLARE_API_TOKEN", "created_at": "...", "updated_at": "..."}]}`; listing and reading need the admin role.
223
224Rules:
225
226- Names use letters, digits and underscores, don't start with a digit, and don't start with `GITHUB_`. Anything else fails with `422 invalid secret name`. Names are stored in uppercase.
227- Send the value as `value`, in plain JSON over HTTPS. A body with only GitHub's sealed-box `encrypted_value` fails with ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest``. There's no `public-key` endpoint.
228- Adding the first secret to an environment without a branch policy sets it to protected branches only (see [The default policy when the first secret is added](#the-default-policy-when-the-first-secret-is-added)).
229- Agents can't set or delete them, whatever their role (`403 agents cannot manage secrets` for an agent with the admin role).
230
231### Environment variables
232
233| Request | Needs | Does |
234|---|---|---|
235| `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables` | write role | Returns `{"total_count": 1, "variables": [{"name", "value", "created_at", "updated_at"}]}`. `per_page` defaults to 10 (at most 100). |
236| `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | write role | Returns one variable, or `404 Not Found`. |
237| `POST /api/v3/repos/{owner}/{repo}/environments/{env}/variables` with `{"name": "...", "value": "..."}` | admin role, people only | Creates a variable (`201`). An existing name fails with `409 Already exists - Variable already exists`. |
238| `PATCH /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` with `{"value": "..."}` and optionally a new `"name"` | admin role, people only | Updates or renames a variable (`204`), or `404 Not Found`. Renaming onto a name that already exists fails with `409 Already exists - Variable already exists`. |
239| `DELETE /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin role, people only | Deletes a variable (`204`). |
240
241```sh
242curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \
243 -H "Authorization: Bearer dfy_..." \
244 -H "Content-Type: application/json" \
245 -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}'
246```
247
248Variable names follow the same rules as secret names (`422 invalid variable name` otherwise) and are stored in uppercase. Variables are not secret: anyone with write access can read them. Agents can't create, change or delete them, whatever their role (`403 agents cannot manage variables` for an agent with the admin role).
249
250### Who receives environment secrets
251
252A job that uses an environment receives its secrets only when all of these hold:
253
2541. The environment's branch policy allows the job's ref (otherwise the job fails, see [Deployment branch policies](#deployment-branch-policies)).
2552. The run isn't on a pull request ref (`refs/pull/<number>/merge`). Environment secrets never go to `pull_request` runs, whatever the policy says.
2563. If the run's code came from an agent, the environment is set to **protected branches only**. With no policy or with custom patterns, those runs get no environment secrets.
257
258A run's code came from an agent when any of these is true:
259
260- An agent started the run, for example with a push, a `workflow_dispatch` or a re-run.
261- The run is for a branch whose last push was made by an agent.
262- The run is for a pull request that an agent opened, or whose head branch an agent pushed to last.
263- The run is a re-run of a run whose code came from an agent, whoever starts the re-run.
264- The run was started by `workflow_run` after a run whose code came from an agent (it acts on that run's artifacts and outputs).
265
266In such a run the job also acts as an agent: its deployment's creator is recorded as an agent, and its `GITHUB_TOKEN` follows the rules for agents (see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)).
267
268When a rule withholds the secrets, the job still runs with the environment's variables, and `${{ secrets.NAME }}` for an environment secret evaluates to an empty string.
269
270| Run | No policy | Protected branches only | Selected branches and tags |
271|---|---|---|---|
272| Started by a person, on an allowed branch or tag | Secrets | Secrets | Secrets |
273| Code from an agent, on an allowed branch | No secrets | Secrets | No secrets |
274| `pull_request` run (`refs/pull/<n>/merge`) | Job runs, no secrets | Job fails (ref not allowed) | Job fails (ref not allowed) |
275| Ref not allowed by the policy | Not applicable | Job fails | Job fails |
276
277`pull_request_target` runs use the base branch as their ref, so they're treated like a run on that branch.
278
279### Precedence
280
281In a job that uses an environment:
282
283- An environment secret replaces a repository or organization secret with the same name (and, in a called reusable workflow, a secret passed by the caller).
284- An environment variable replaces a repository variable with the same name.
285- `GITHUB_TOKEN` and `DIFFINERY_TOKEN` are always the job's own token.
286
287Values are read when the job starts, so changes affect jobs that start afterwards. Environment secrets are masked in logs like every other secret. See [Secrets and variables](/docs/actions/secrets-and-variables) for masking, names and repository secrets.
288
289## Use an environment in a job
290
291### Syntax
292
293Name the environment with a string:
294
295```yaml
296jobs:
297 deploy:
298 runs-on: ubuntu-latest
299 environment: production
300 steps:
301 - run: ./deploy.sh
302```
303
304Or with a mapping that also gives the URL of the deployed site:
305
306```yaml
307jobs:
308 deploy:
309 runs-on: ubuntu-latest
310 environment:
311 name: production
312 url: https://www.example.com
313 steps:
314 - run: ./deploy.sh
315```
316
317| Key | Description |
318|---|---|
319| `name` | The environment's name (required in the mapping form). Expressions are allowed. |
320| `url` | Optional. Stored as the deployment status's `environment_url` when it's an `http://` or `https://` URL; other values are ignored. Expressions are allowed. |
321
322Both values can use the `github`, `inputs`, `vars`, `matrix`, `strategy` and `needs` contexts. Other contexts evaluate to empty:
323
324- A `name` that evaluates to an empty string fails the job with `invalid environment name ''`.
325- A `url` that refers to `steps` (for example `${{ steps.deploy.outputs.url }}`, which GitHub allows) is ignored, so the deployment has no environment URL. Compute the URL from `inputs`, `vars`, `matrix` or a job output in `needs` instead, or post a deployment status yourself (see [Deployment statuses](#deployment-statuses)).
326
327```yaml
328jobs:
329 deploy:
330 runs-on: ubuntu-latest
331 strategy:
332 matrix:
333 target: [staging, production]
334 environment:
335 name: ${{ matrix.target }}
336 url: https://${{ matrix.target }}.example.com
337 steps:
338 - run: ./deploy.sh "${{ matrix.target }}"
339```
340
341`environment` belongs on jobs that have `steps`. On a job that calls a reusable workflow (`uses:`), it produces the warning `unknown reusable-workflow job key 'environment'` and is ignored; put `environment:` on the jobs inside the called workflow instead (see [Reusable workflows](#reusable-workflows)).
342
343### What happens when the job starts
344
345When a hosted or self-hosted runner starts a job with `environment:`, Diffinery:
346
3471. Evaluates `name` and `url`.
3482. Creates the environment if it doesn't exist (with no branch policy).
3493. Checks the branch policy against the run's ref, and fails the job if the ref isn't allowed.
3504. Creates a deployment for the job: `ref` is the branch or tag name (or `refs/pull/<n>/merge`), `sha` is the commit the run checked out, `task` is `deploy`, `payload` is `{}`, the creator is the person or agent who started the run (an agent when the run's code came from an agent), and `production_environment` is `true` when the environment is named `production` in any case (`Production` counts).
3515. Sends a `deployment` webhook event with the action `created`, then adds an `in_progress` status, with `log_url` set to the job's page (`https://diffinery.com/acme/website/actions/runs/<run id>/job/<job id>`) and `environment_url` from `url`.
3526. Passes the environment's variables to the job, and its secrets when [the rules](#who-receives-environment-secrets) allow it.
353
354A job creates one deployment, even if its runner fetches the job more than once. A re-run creates a new deployment, because it's a new job. The deployment's commit statuses and checks aren't required to have passed, unlike [deployments created with the API](#required-contexts).
355
356### Automatic deployment statuses
357
358When the job finishes, its deployment gets a final status with the same `log_url` and `environment_url`:
359
360| Job conclusion | Deployment status |
361|---|---|
362| `success` | `success` |
363| `cancelled` or `skipped` | `inactive` |
364| Anything else (`failure`, timed out) | `failure` |
365
366A `success` status also marks older deployments to the same environment as `inactive` when their latest status is `success`, unless they are transient or production deployments. Deployments created after this one are left alone. See [auto_inactive](#deployment-statuses).
367
368### Reusable workflows
369
370Jobs inside a called workflow can have `environment:`. The environment, its branch policy, secrets and variables come from the **calling** repository (the repository whose run it is), and the policy is checked against the caller's ref. Environment secrets are added to the job even though the caller didn't pass them in `secrets:`, as on GitHub. The environment name can use the called workflow's `inputs`:
371
372```yaml
373# .github/workflows/deploy.yml
374on:
375 workflow_call:
376 inputs:
377 target:
378 type: string
379 required: true
380
381jobs:
382 deploy:
383 runs-on: ubuntu-latest
384 environment:
385 name: ${{ inputs.target }}
386 url: https://${{ inputs.target }}.example.com
387 steps:
388 - uses: actions/checkout@v4
389 - run: npx wrangler deploy --env "${{ inputs.target }}"
390 env:
391 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
392```
393
394```yaml
395# .github/workflows/release.yml
396on:
397 push:
398 branches: [main]
399
400jobs:
401 staging:
402 uses: ./.github/workflows/deploy.yml
403 with:
404 target: staging
405 production:
406 needs: staging
407 uses: ./.github/workflows/deploy.yml
408 with:
409 target: production
410```
411
412Here `CLOUDFLARE_API_TOKEN` can be a different environment secret in `staging` and in `production`. See [Reusable workflows](/docs/actions/reusable-workflows).
413
414### Choosing an environment when you run a workflow manually
415
416A `workflow_dispatch` input with `type: environment` is accepted. It's shown as a text field when you run the workflow from the web UI, and its value isn't checked against the repository's environments. Use it like a string input:
417
418```yaml
419on:
420 workflow_dispatch:
421 inputs:
422 target:
423 type: environment
424 required: true
425
426jobs:
427 deploy:
428 runs-on: ubuntu-latest
429 environment: ${{ inputs.target }}
430 steps:
431 - run: ./deploy.sh
432```
433
434Because a dispatched run can name any environment, the environment's branch policy is what keeps a branch from deploying where it shouldn't.
435
436### Example: staging on main, production on version tags
437
4381. Set up the environments (as a repository admin):
439
440 ```sh
441 api=https://diffinery.com/api/v3/repos/acme/website
442 auth="Authorization: Bearer dfy_..."
443
444 # staging: protected branches only (add a branch protection rule for main first)
445 curl -X PUT "$api/environments/staging" -H "$auth" -H "Content-Type: application/json" \
446 -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}'
447
448 # production: only tags that match v*
449 curl -X PUT "$api/environments/production" -H "$auth" -H "Content-Type: application/json" \
450 -d '{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}'
451 curl -X POST "$api/environments/production/deployment-branch-policies" -H "$auth" \
452 -H "Content-Type: application/json" -d '{"name": "v*", "type": "tag"}'
453
454 # A different token for each environment
455 curl -X PUT "$api/environments/staging/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \
456 -H "Content-Type: application/json" -d '{"value": "cf_staging_..."}'
457 curl -X PUT "$api/environments/production/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \
458 -H "Content-Type: application/json" -d '{"value": "cf_production_..."}'
459 ```
460
4612. Write the workflow:
462
463 ```yaml
464 name: Deploy
465 on:
466 push:
467 branches: [main]
468 tags: ["v*"]
469
470 jobs:
471 staging:
472 if: github.ref == 'refs/heads/main'
473 runs-on: ubuntu-latest
474 environment:
475 name: staging
476 url: https://staging.example.com
477 steps:
478 - uses: actions/checkout@v4
479 - run: npm ci && npm run build
480 - run: npx wrangler deploy --env staging
481 env:
482 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
483
484 production:
485 if: startsWith(github.ref, 'refs/tags/v')
486 runs-on: ubuntu-latest
487 environment:
488 name: production
489 url: https://www.example.com
490 steps:
491 - uses: actions/checkout@v4
492 - run: npm ci && npm run build
493 - run: npx wrangler deploy --env production
494 env:
495 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
496 ```
497
498Pushing to `main` deploys to staging; a person pushing the tag `v1.4.0` deploys to production. A tag pushed by an agent fails the production job with the "not allowed to deploy" message.
499
500## Deployments API
501
502You can create deployments yourself, for example from a deploy tool outside Diffinery Actions, and report their progress with statuses. The endpoints follow GitHub's REST API. Creating deployments and statuses needs the write role and a token with the `write` scope; reading needs read access. Agents with write access can create deployments and statuses.
503
504A workflow step can create deployments and statuses with `GITHUB_TOKEN` when the job has `deployments: write` in its `permissions:`. Without it, the request fails with `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: deployments: write)`, or `403 write access required` when the token has no write scope at all. Often `environment:` on the job is simpler, because it records the deployment for you. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions).
505
506```yaml
507jobs:
508 deploy:
509 runs-on: ubuntu-latest
510 permissions:
511 contents: read
512 deployments: write
513 steps:
514 - run: |
515 curl -fsS -X POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/deployments" \
516 -H "Authorization: Bearer $GITHUB_TOKEN" \
517 -H "Content-Type: application/json" \
518 -d '{"ref": "${{ github.sha }}", "environment": "preview", "required_contexts": []}'
519 env:
520 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
521```
522
523### Create a deployment
524
525```sh
526curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments \
527 -H "Authorization: Bearer dfy_..." \
528 -H "Content-Type: application/json" \
529 -d '{"ref": "main", "environment": "production", "description": "Deploy 1.4.0", "required_contexts": ["ci/build"]}'
530```
531
532| Field | Default | Description |
533|---|---|---|
534| `ref` | required | A branch, tag or commit SHA. Missing: `422 ref is required`. Unknown: `422 no ref found for: <ref>`. |
535| `environment` | `production` | The environment's name. It's created if it doesn't exist. |
536| `task` | `deploy` | Free text. Only the first 100 characters are kept. |
537| `payload` | `{}` | Any JSON value, or a string. Returned as it was sent. |
538| `description` | `null` | Free text. Only the first 1,000 characters are kept. |
539| `required_contexts` | every context | See [Required contexts](#required-contexts). Up to 100 strings. |
540| `transient_environment` | `false` | Marks the environment as temporary (it's skipped by `auto_inactive`). |
541| `production_environment` | `true` when `environment` is `production`, in any case | Marks a production deployment (it's skipped by `auto_inactive`). |
542| `auto_merge` | | Accepted and ignored. Diffinery never merges the default branch into `ref`. |
543
544The response is `201 Created` with the deployment (some user fields left out here):
545
546```json
547{
548 "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7",
549 "id": 7,
550 "node_id": "RGVwbG95bWVudDphY21lL3dlYnNpdGUvNw",
551 "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8",
552 "ref": "main",
553 "task": "deploy",
554 "payload": {},
555 "original_environment": "production",
556 "environment": "production",
557 "description": "Deploy 1.4.0",
558 "creator": { "login": "diffy", "type": "User" },
559 "created_at": "2026-10-13T09:30:00Z",
560 "updated_at": "2026-10-13T09:30:00Z",
561 "statuses_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses",
562 "repository_url": "https://diffinery.com/api/v3/repos/acme/website",
563 "transient_environment": false,
564 "production_environment": true,
565 "performed_via_github_app": null
566}
567```
568
569### Required contexts
570
571Before creating a deployment, Diffinery checks the commit's checks and commit statuses, as GitHub does:
572
573- **`required_contexts` left out:** every check and status context reported on the commit must have passed. Contexts reported only by agents aren't counted.
574- **A list of names:** each named context must have passed. A name that nothing reported counts as not passed.
575- **`[]`:** nothing is checked.
576
577A context passes when its result is `success`, `neutral` or `skipped`. When several reporters use the same name, the same rules as for required checks in branch protection apply: Diffinery Actions decides a check it reported, any failure fails, anything unfinished is pending, and reports from agents never count. See [Checks and commit statuses](/docs/collaboration/checks-and-statuses).
578
579If a context hasn't passed, the request fails with `409`:
580
581```json
582{
583 "message": "Conflict: Commit status checks failed for main.",
584 "errors": [
585 { "contexts": [{ "context": "ci/build", "state": "pending" }], "resource": "Deployment", "field": "required_contexts" }
586 ],
587 "documentation_url": "https://docs.github.com/rest"
588}
589```
590
591`state` is `failure`, `pending` or `missing`. Wait for the checks to finish, or pass `required_contexts` with only the contexts you care about.
592
593Deployments that workflow jobs create for `environment:` skip this check.
594
595### List, get and delete deployments
596
597```sh
598# Newest first; filter by sha, ref, task or environment (exact match)
599curl "https://diffinery.com/api/v3/repos/acme/website/deployments?environment=production&per_page=10" \
600 -H "Authorization: Bearer dfy_..."
601
602curl https://diffinery.com/api/v3/repos/acme/website/deployments/7 \
603 -H "Authorization: Bearer dfy_..."
604
605curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/deployments/7 \
606 -H "Authorization: Bearer dfy_..."
607```
608
609- The list takes `per_page` (default 30, at most 100) and `page`, and sends a `Link` header.
610- The `ref` filter compares the stored ref exactly. Deployments created by workflow jobs store the branch or tag name (`main`, `v1.4.0`), not `refs/heads/main`.
611- A missing deployment returns `404 deployment not found`.
612- Deleting needs the write role and returns `204 No Content`. A deployment whose latest status is `success` can't be deleted while other deployments to the same environment exist: `422 We cannot delete an active deployment unless it is the only deployment in a given environment.` Post an `inactive` status first.
613
614### Deployment statuses
615
616```sh
617curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses \
618 -H "Authorization: Bearer dfy_..." \
619 -H "Content-Type: application/json" \
620 -d '{"state": "success", "log_url": "https://ci.example.com/builds/812", "environment_url": "https://www.example.com", "description": "Deployed"}'
621```
622
623| Field | Description |
624|---|---|
625| `state` | Required: `error`, `failure`, `inactive`, `in_progress`, `queued`, `pending` or `success`. Anything else fails with `422 state must be one of error, failure, inactive, in_progress, queued, pending, success`. |
626| `log_url` | Link to the deployment's output. `target_url` is accepted as an older name for it. |
627| `environment_url` | Link to the deployed environment. |
628| `description` | Up to 140 characters; longer text is cut. |
629| `environment` | Moves the deployment to another environment (created if needed). The deployment's `original_environment` keeps the first name. |
630| `auto_inactive` | Default `true`. With a `success` state, older deployments to the same environment whose latest status is `success` get an `inactive` status, except transient and production deployments. Deployments created after this one are left alone. Send `false` to keep them all. |
631
632URLs must start with `http://` or `https://` (`422 URLs must be http(s)`). The response is `201 Created`:
633
634```json
635{
636 "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses/15",
637 "id": 15,
638 "node_id": "RGVwbG95bWVudFN0YXR1czphY21lL3dlYnNpdGUvMTU",
639 "state": "success",
640 "creator": { "login": "diffy", "type": "User" },
641 "description": "Deployed",
642 "environment": "production",
643 "target_url": "https://ci.example.com/builds/812",
644 "log_url": "https://ci.example.com/builds/812",
645 "environment_url": "https://www.example.com",
646 "created_at": "2026-10-13T09:34:10Z",
647 "updated_at": "2026-10-13T09:34:10Z",
648 "deployment_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7",
649 "repository_url": "https://diffinery.com/api/v3/repos/acme/website",
650 "performed_via_github_app": null
651}
652```
653
654List a deployment's statuses, newest first, with `GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses` (`per_page`, `page`), and get one with `GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` (`404 deployment status not found` if it doesn't exist).
655
656## Endpoint summary
657
658All paths are under `https://diffinery.com/api/v3`. Every endpoint is also in the [API reference](/docs/api).
659
660| Method and path | Needs |
661|---|---|
662| `GET /repos/{owner}/{repo}/deployments` | read |
663| `POST /repos/{owner}/{repo}/deployments` | write |
664| `GET /repos/{owner}/{repo}/deployments/{id}` | read |
665| `DELETE /repos/{owner}/{repo}/deployments/{id}` | write |
666| `GET /repos/{owner}/{repo}/deployments/{id}/statuses` | read |
667| `POST /repos/{owner}/{repo}/deployments/{id}/statuses` | write |
668| `GET /repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` | read |
669| `GET /repos/{owner}/{repo}/environments` | read |
670| `GET /repos/{owner}/{repo}/environments/{env}` | read |
671| `PUT /repos/{owner}/{repo}/environments/{env}` | admin, people only |
672| `DELETE /repos/{owner}/{repo}/environments/{env}` | admin, people only |
673| `GET /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | read |
674| `POST /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | admin, people only |
675| `DELETE /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies/{id}` | admin, people only |
676| `GET /repos/{owner}/{repo}/environments/{env}/secrets` and `.../secrets/{name}` | admin |
677| `PUT` and `DELETE /repos/{owner}/{repo}/environments/{env}/secrets/{name}` | admin, people only |
678| `GET /repos/{owner}/{repo}/environments/{env}/variables` and `.../variables/{name}` | write |
679| `POST /repos/{owner}/{repo}/environments/{env}/variables` | admin, people only |
680| `PATCH` and `DELETE /repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin, people only |
681
682The token's scope caps the role: a `read` token only reads (any other request gets `403 this token needs the write scope`), a `write` token also writes, and only an `admin` token reaches the admin endpoints. Requests without a token can read the environments and deployments of a public repository, but any change gets `401 authentication required`. On an archived repository, every request other than a read fails with `403 repository is archived (read-only)`.
683
684## Webhooks
685
686Repository webhooks receive two events, with the GitHub event names in the `X-GitHub-Event` header. In the web UI's **Add a webhook** form, choose **Everything** to receive them; with the API, list `deployment` and `deployment_status` in the webhook's `events`. See [Webhooks](/docs/repositories/webhooks).
687
688### deployment
689
690Sent when a deployment is created with the REST API:
691
692```json
693{
694 "action": "created",
695 "deployment": {
696 "id": 7,
697 "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8",
698 "ref": "main",
699 "task": "deploy",
700 "environment": "production",
701 "description": "Deploy 1.4.0",
702 "payload": {},
703 "created_at": "2026-10-13T09:30:00.000Z"
704 },
705 "repository": { "full_name": "acme/website" },
706 "sender": { "login": "diffy", "type": "User" }
707}
708```
709
710Deployments that workflow jobs create for `environment:` send this event too, when the job starts. Their `description` is `null`, `task` is `deploy` and `payload` is `{}`.
711
712### deployment_status
713
714Sent for every status created with the REST API or by a workflow job (the `in_progress` status when the job starts and the final status when it ends):
715
716```json
717{
718 "action": "created",
719 "deployment_status": {
720 "id": 15,
721 "state": "success",
722 "description": "",
723 "environment": "production",
724 "environment_url": "https://www.example.com",
725 "log_url": "https://diffinery.com/acme/website/actions/runs/01J9Z.../job/01J9Z...",
726 "created_at": "2026-10-13T09:34:10.000Z"
727 },
728 "deployment": { "id": 7, "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "environment": "production" },
729 "repository": { "full_name": "acme/website" },
730 "sender": { "login": "diffy", "type": "User" }
731}
732```
733
734The `inactive` statuses that `auto_inactive` adds to older deployments don't send events. The `repository` and `sender` objects have more fields than shown; see [Webhook events](/docs/reference/webhook-events).
735
736Workflows with `on: deployment` or `on: deployment_status` are accepted but never started. To run something after a deployment, use `needs:` on a later job, or a webhook.
737
738## Agents and environments
739
740Environments guard deployment credentials, so agents are limited:
741
742- Agents can't create, change or delete environments or their branch policies, whatever their role. An agent with the write role gets `403 admin access required`; one that was given the admin role gets `403 only repository admins (people, not agents) can manage environments`.
743- Agents can't set or delete environment secrets: an agent with the admin role gets `403 agents cannot manage secrets`.
744- Agents can't create, change or delete environment variables: an agent with the admin role gets `403 agents cannot manage variables`.
745- Runs whose code came from an agent only get environment secrets from environments set to protected branches only, and never on pull request refs (see [Who receives environment secrets](#who-receives-environment-secrets)).
746- Tags that an agent created or moved never match an environment's tag patterns.
747- Agents with write access can create deployments and deployment statuses with the API (with a `write` token), and can read environments and environment variables.
748
749Together with branch protection (agents can't push to protected branches unless a rule allows it, and pull requests opened by agents need a human approval), this means an agent can't get production credentials into code that no person reviewed. See [Agents](/docs/agents/overview).
750
751## Audit log
752
753Changes to environments made with the API are recorded in the audit log, without secret values:
754
755| Action | When |
756|---|---|
757| `environment.update` | An environment is created or updated with `PUT` (with the new `deployment_branch_policy`). |
758| `environment.delete` | An environment is deleted. |
759| `environment.branch_policy.create` | A branch or tag pattern is added. |
760| `environment.branch_policy.delete` | A pattern is removed. |
761| `environment.secret.set` | An environment secret is created or replaced (name only). |
762| `environment.secret.delete` | An environment secret is deleted. |
763
764Environment variable changes aren't recorded. See [Audit log](/docs/security/audit-log).
765
766## What isn't supported
767
768- **Protection rules other than branch policies.** Required reviewers, wait timers and custom deployment protection rules don't exist. Setting `reviewers` or `wait_timer` fails with `422 required reviewers and wait timers are not supported yet`, so a job never waits for approval.
769- **A web UI.** Environments, their secrets and variables, and deployments are managed with the REST API only. The web UI shows no deployments.
770- **Reading or updating a single branch pattern.** You can list, add and delete patterns, but not `GET` or `PUT` one by id.
771- **Sealed-box secrets.** Send `value`, not `encrypted_value`.
772- **Organization-level environments and environment secrets.** Environments belong to one repository.
773- **OIDC environment claims.** OIDC tokens for jobs with `environment:` have no `environment` claim, and their `sub` stays `repo:<owner>/<repo>:ref:<ref>`. See [OpenID Connect (OIDC)](/docs/actions/oidc).
774- **`on: deployment` and `on: deployment_status` workflow triggers.** Accepted but never started.
775
776## Differences from GitHub
777
778| Area | GitHub | Diffinery |
779|---|---|---|
780| Managing environments | Web UI and API | API only |
781| Required reviewers, wait timers | Supported | Refused with `422` |
782| Admin bypass | Admins can bypass protection rules | Nobody bypasses the branch policy; `can_admins_bypass` is always `true` but has no effect |
783| Default branch policy | All branches | All refs, until the first secret is added; then protected branches only |
784| Protected branches | Branches with protection rules | Branches matched by a branch protection rule; the default branch counts only when a rule matches it |
785| Pull request runs | Fork pull requests get no secrets | No `pull_request` run gets environment secrets |
786| Agents | No equivalent | Agents can't manage environments, get environment secrets only under "protected branches only", and their tags never match tag patterns |
787| `environment.url` | Can use `steps` outputs | `steps` references are ignored |
788| Deployment `auto_merge` | Merges the default branch into the ref | Ignored |
789| Branch patterns | `fnmatch` syntax with character classes | `*`, `**` and `?` only, unlike workflow filters |
790| OIDC | `environment` claim and an environment-based `sub` | No `environment` claim |
791
792## Troubleshooting
793
794| Problem | Cause and fix |
795|---|---|
796| `<ref> is not allowed to deploy to <env> due to environment protection rules.` | The environment's branch policy doesn't allow the run's ref. Check the policy with `GET .../environments/<env>`: with protected branches only, add a branch protection rule for the branch; with custom patterns, add a matching pattern. A tag last pushed by an agent never matches. |
797| The deploy job fails on a pull request | Pull request refs never pass a branch policy. Add `if: github.event_name == 'push'` (or a branch check) to the deploy job. |
798| `invalid environment name ''` | `environment.name` evaluated to an empty string, for example because it uses `steps`, or a `needs` output that's empty. Use `inputs`, `vars`, `matrix`, `needs` or a literal. |
799| An environment secret is empty in the job | The run is on a pull request ref, or the run's code came from an agent and the environment isn't set to protected branches only, or the name is wrong. Variables are still set. |
800| A new environment suddenly refuses a branch | Adding its first secret set the policy to protected branches only. Add a branch protection rule, or switch to custom patterns. |
801| `403 only repository admins (people, not agents) can manage environments` | Use a person's token with the `admin` scope and the admin role on the repository. |
802| `403 agents cannot manage secrets` | Use a person's token. |
803| `403 agents cannot manage variables` | Agents can't change environment variables. Use a person's token. |
804| `403 this token needs the write scope` | The token has only the `read` scope. Use a `write` token for deployments and statuses, or an `admin` token for environment settings. |
805| `422 required reviewers and wait timers are not supported yet` | Remove `reviewers` and `wait_timer` from the request. Tools that copy GitHub environments often send them. |
806| `422 set exactly one of protected_branches and custom_branch_policies` | Set one to `true` and the other to `false`, or send `null` to remove the policy. |
807| `422 enable custom_branch_policies on the environment first` | Set `custom_branch_policies: true` with `PUT` before adding patterns. |
808| `409 Conflict: Commit status checks failed for <ref>.` | A context on the commit hasn't passed. Wait for it, or pass `required_contexts` (`[]` to skip the check). |
809| `422 We cannot delete an active deployment unless it is the only deployment in a given environment.` | Post an `inactive` status to the deployment first. |
810| `403 Resource not accessible by integration` or `403 write access required` when a workflow step creates a deployment | The job lacks `deployments: write`. Add it to the job's `permissions:`, or use `environment:` on the job. |
811| `403 admin access required` | Managing environments, branch patterns, environment secrets and variable changes needs the admin role and an `admin`-scoped token. |
812| The deployment has no environment URL | `environment.url` used `steps`, or wasn't an `http(s)` URL. |