Environment reviewers and wait timers hold deploy jobs until a person approves (phase 3f) (#116)
* Environment reviewers and wait timers hold deploy jobs until a person approves (phase 3f) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 * Environment reviewers: compare self-review by account id and check access when deciding prevent_self_review now compares account ids (who started the run, earlier attempts, and the owner of an agent among them), so a rename doesn't get around it. A reviewer's access is checked again when they decide, and only granted access counts, so a reviewer removed from a public repository can't approve. Today's waiting deployments follow the same rules. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 * e2e: let the wait-timer step ride out a busy machine Sit out the timer, then check the job every few seconds for up to five minutes before claiming it, instead of hammering the runner queue with a 150-second deadline. Test only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 --------- Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 86edf57commit ebca377cd3d08d6b1048124f3139b62bc6f95a72Browse files
29 files changed+1338−95
Files (29)
- apps/diffinery/docs/actions/compatibility.md+4−4
- apps/diffinery/docs/actions/environments.md+120−22
- apps/diffinery/docs/actions/workflow-syntax.md+2−2
- apps/diffinery/docs/agents/today.md+6−0
- apps/diffinery/docs/migrate-from-github.md+3−3
- apps/diffinery/docs/reference/rest-api.md+2−2
- apps/diffinery/src/agentic/today-repo.ts+57−1
- apps/diffinery/src/agentic/today.ts+17−4
- apps/diffinery/src/agentic/web.tsx+27−0
- apps/diffinery/src/app.ts+1−0
- apps/diffinery/src/docs/api-meta.ts+21−3
- apps/diffinery/src/http/deployments.ts+75−12
- apps/diffinery/src/http/environment-reviews.ts+109−0
- apps/diffinery/src/http/gh.ts+2−0
- apps/diffinery/src/mail/notify.ts+25−0
- apps/diffinery/src/mail/samples.ts+1−0
- apps/diffinery/src/mail/templates.ts+16−0
- apps/diffinery/src/mail/types.ts+2−0
- apps/diffinery/src/repo/RepoDO.ts+12−0
- apps/diffinery/src/repo/actions.ts+245−7
- apps/diffinery/src/repo/deployments.ts+134−27
- apps/diffinery/src/repo/schema.ts+18−0
- apps/diffinery/src/runner/workflow.ts+21−3
- apps/diffinery/src/web/actions.tsx+103−2
- apps/diffinery/src/web/repo-settings.tsx+158−1
- apps/diffinery/src/web/ui.tsx+1−1
- apps/diffinery/test/e2e/security.test.mjs+147−0
- apps/diffinery/test/unit/today.test.ts+8−0
- packages/shared/src/events.ts+1−1
modifiedapps/diffinery/docs/actions/compatibility.md+4−4View file
| @@ -22,7 +22,7 @@ | ||
| 22 | 22 | | Job containers and service containers | Supported | [Docker in workflows](/docs/actions/docker) | |
| 23 | 23 | | Reusable workflows | Supported, except a matrix on the calling job | [Reusable workflows](/docs/actions/reusable-workflows) | |
| 24 | 24 | | Secrets and variables | Repository, organization and environment secrets and variables | [Secrets and variables](/docs/actions/secrets-and-variables) | |
| 25 | -| Environments | Supported: branch policies, environment secrets and variables, deployments. No required reviewers or wait timers. | [Secrets, variables and environments](#secrets-variables-and-environments) | | |
| 25 | +| Environments | Supported: branch policies, required reviewers (people), wait timers, environment secrets and variables, deployments. | [Secrets, variables and environments](#secrets-variables-and-environments) | | |
| 26 | 26 | | `GITHUB_TOKEN` | Follows `permissions:` per endpoint, as on GitHub; read-only by default in new repositories | [GITHUB_TOKEN and permissions](#github_token-and-permissions) | |
| 27 | 27 | | OIDC | Supported, with Diffinery as the issuer | [OIDC](/docs/actions/oidc) | |
| 28 | 28 | | Windows and macOS hosted runners | Not supported yet | [Runners](#runners) | |
| @@ -65,7 +65,7 @@ | ||
| 65 | 65 | | Key | What happens on Diffinery | |
| 66 | 66 | | --- | --- | |
| 67 | 67 | | `jobs.<id>.runs-on` | Supported, including the `matrix` context (`runs-on: ${{ matrix.os }}`), but only Linux labels are hosted. The `group` key is ignored. | |
| 68 | -| `jobs.<id>.environment` | Supported: the branch policy is enforced, the environment's secrets and variables are added and a deployment is recorded. Required reviewers and wait timers aren't supported, and `url` can't use step outputs. See [environment](/docs/actions/workflow-syntax#environment). | | |
| 68 | +| `jobs.<id>.environment` | Supported: the branch policy is enforced, the environment's secrets and variables are added and a deployment is recorded. Required reviewers and wait timers hold the job before it starts, and `url` can't use step outputs. See [environment](/docs/actions/workflow-syntax#environment). | | |
| 69 | 69 | | `jobs.<id>.snapshot` | Accepted and ignored. | |
| 70 | 70 | | `jobs.<id>.timeout-minutes` | Default and maximum 360. Larger values are capped at 360. | |
| 71 | 71 | | `jobs.<id>.strategy.matrix` | At most 256 jobs per matrix. | |
| @@ -361,7 +361,7 @@ | ||
| 361 | 361 | | Organization variables | Supported, through the REST API. Every repository in the organization sees them (no `private` or `selected` visibility). See [Organization variables](/docs/actions/secrets-and-variables#organization-variables). | |
| 362 | 362 | | Environments with deployment branch policies (protected branches only, or branch and tag patterns) | Supported, through the REST API. See [Environments](/docs/actions/environments). | |
| 363 | 363 | | Environment secrets and variables | Supported, through the REST API. They win over repository and organization values with the same name. Pull request runs never get environment secrets, and runs started by agents get them only from environments limited to protected branches. | |
| 364 | -| Environment protection rules: required reviewers, wait timers | Not supported yet. The API refuses them with `required reviewers and wait timers are not supported yet`. | | |
| 364 | +| Team reviewers and custom deployment protection rules | Not supported. Required reviewers are people. | | |
| 365 | 365 | | Setting secrets with a sealed-box `encrypted_value` | Not supported yet. Send the plain `value` over HTTPS; it's encrypted at rest. | |
| 366 | 366 | | Secrets in `pull_request` runs | Provided, because every pull request comes from a branch of the same repository | |
| 367 | 367 | | Changing secrets and variables with an agent's token | Refused: only people manage secrets and variables | |
| @@ -386,7 +386,7 @@ | ||
| 386 | 386 | 5. Install tools the hosted image doesn't have (Go, Java, Ruby, Rust, PowerShell, cloud CLIs) with a setup action or `apt-get`. |
| 387 | 387 | 6. Check what each job does with `GITHUB_TOKEN`. In a new repository the default token only reads, so give jobs that push, tag, comment, label or merge the `permissions:` they need, or have an admin set the repository default to write (see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions)). Jobs that change workflow files, or that open or approve pull requests without the repository allowing it, need a personal or agent token from a secret. |
| 388 | 388 | 7. Give `actions/checkout` of another private repository a token from a secret. |
| 389 | -8. Recreate your environments, their branch policies, secrets and variables through the REST API (see [Environments](/docs/actions/environments)), and remove any reliance on environment approvals or wait timers. | |
| 389 | +8. Recreate your environments, their branch policies, secrets and variables through the REST API (see [Environments](/docs/actions/environments)), and set required reviewers by Diffinery login (GitHub's numeric ids and teams don't carry over). | |
| 390 | 390 | 9. Replace `github.graphql` calls in `actions/github-script` with REST calls. |
| 391 | 391 | 10. Replace `ghcr.io` with `diffinery.com` in image names and logins; see [Container registry](/docs/packages/container-registry#migrating-from-ghcrio). |
| 392 | 392 | 11. Replace hard-coded `github.com` and `api.github.com` URLs with `${{ github.server_url }}` and `${{ github.api_url }}`. |
modifiedapps/diffinery/docs/actions/environments.md+120−22View file
| @@ -9,8 +9,10 @@ | ||
| 9 | 9 | |
| 10 | 10 | | Concept | What it is | |
| 11 | 11 | |---|---| |
| 12 | -| Environment | A name in one repository (`production`), with an optional deployment branch policy, secrets and variables. | | |
| 12 | +| Environment | A name in one repository (`production`), with an optional deployment branch policy, protection rules, secrets and variables. | | |
| 13 | 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 | +| Required reviewers | Up to 6 people, one of whom must approve before a run's jobs deploy to the environment. | | |
| 15 | +| Wait timer | Minutes a job waits before it deploys, up to 43,200 (30 days). | | |
| 14 | 16 | | 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 | 17 | | Environment variable | A plain value that jobs using the environment receive in the `vars` context. | |
| 16 | 18 | | 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. | |
| @@ -23,7 +25,7 @@ | ||
| 23 | 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. |
| 24 | 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. |
| 25 | 27 | |
| 26 | -There'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`. | |
| 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. | |
| 27 | 29 | |
| 28 | 30 | ## Create an environment |
| 29 | 31 | |
| @@ -67,29 +69,29 @@ | ||
| 67 | 69 | "node_id": "RW52aXJvbm1lbnQ6YWNtZS93ZWJzaXRlLzE", |
| 68 | 70 | "name": "production", |
| 69 | 71 | "url": "https://diffinery.com/api/v3/repos/acme/website/environments/production", |
| 70 | - "html_url": "https://diffinery.com/acme/website/actions", | |
| 72 | + "html_url": "https://diffinery.com/acme/website/settings/environments/production", | |
| 71 | 73 | "created_at": "2026-10-13T09:00:00Z", |
| 72 | 74 | "updated_at": "2026-10-13T09:00:00Z", |
| 73 | - "can_admins_bypass": true, | |
| 74 | - "protection_rules": [{ "id": 1, "node_id": "QnJhbmNoUG9saWN5OjE", "type": "branch_policy" }], | |
| 75 | + "can_admins_bypass": false, | |
| 76 | + "protection_rules": [{ "id": 13, "node_id": "QnJhbmNoUG9saWN5OjE", "type": "branch_policy" }], | |
| 75 | 77 | "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false } |
| 76 | 78 | } |
| 77 | 79 | ``` |
| 78 | 80 | |
| 79 | 81 | Notes on the fields: |
| 80 | 82 | |
| 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. | |
| 83 | +- `protection_rules` lists a `wait_timer` entry (with `wait_timer`), a `required_reviewers` entry (with `prevent_self_review` and `reviewers`, each `{"type": "User", "reviewer": {...}}`) and a `branch_policy` entry, for the rules the environment has. See [Required reviewers and wait timers](#required-reviewers-and-wait-timers). | |
| 84 | +- `can_admins_bypass` is always `false`: nobody bypasses an environment's rules, admins included. | |
| 85 | +- `html_url` is the environment's page under **Settings → Environments**. | |
| 84 | 86 | |
| 85 | 87 | `PUT` on an existing environment updates it. Fields you leave out keep their values. The body accepts: |
| 86 | 88 | |
| 87 | 89 | | Field | Effect | |
| 88 | 90 | |---|---| |
| 89 | 91 | | `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. | | |
| 92 | +| `wait_timer` | Minutes a job waits before it deploys, from `0` to `43200`. Anything else fails with `422 wait_timer must be a whole number of minutes from 0 to 43200`. | | |
| 93 | +| `reviewers` | Up to 6 required reviewers, each `{"type": "User", "id": <id>}` with the numeric `id` Diffinery's API shows for the person, or `{"type": "User", "login": "alice"}`. `[]` or `null` removes them. Each must be a person with at least read access: an agent fails with `422 reviewers[0]: <login> is an agent; only people can be required reviewers`, and `{"type": "Team"}` fails because Diffinery has no teams. | | |
| 94 | +| `prevent_self_review` | `true` stops whoever started a run from approving it. See [Prevent self-review](#prevent-self-review). | | |
| 93 | 95 | |
| 94 | 96 | ### List, get and delete environments |
| 95 | 97 | |
| @@ -191,6 +193,91 @@ | ||
| 191 | 193 | |
| 192 | 194 | When 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 | 195 | |
| 196 | +## Required reviewers and wait timers | |
| 197 | + | |
| 198 | +Protection rules hold a job before it starts. When a job that names the environment is ready to run, and the environment has required reviewers or a wait timer, the job waits. No runner starts, no token is minted and no secret leaves Diffinery until the rules pass. The job's status is `waiting` (shown as **Waiting for approval** in the web UI), the run's status is `waiting`, and the job's deployment gets a `waiting` status. A waiting job holds no concurrency group and none of the repository's hosted-runner slots, so other jobs keep running. | |
| 199 | + | |
| 200 | +The branch policy is checked first. A ref that may never deploy to the environment fails at once, without asking anyone. | |
| 201 | + | |
| 202 | +Set the rules under **Settings → Environments → (environment)**, or with the API: | |
| 203 | + | |
| 204 | +```sh | |
| 205 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \ | |
| 206 | + -H "Authorization: Bearer dfy_..." \ | |
| 207 | + -H "Content-Type: application/json" \ | |
| 208 | + -d '{"reviewers": [{"type": "User", "login": "alice"}, {"type": "User", "login": "bob"}], "wait_timer": 10, "prevent_self_review": true}' | |
| 209 | +``` | |
| 210 | + | |
| 211 | +### Required reviewers | |
| 212 | + | |
| 213 | +- One approval from any listed reviewer lets the run's jobs for that environment go on. A rejection fails them, with `The deployment to production was rejected by alice: <comment>`, and jobs that need them are skipped as usual. | |
| 214 | +- The first decision on an environment in a run is the one that counts. Approving covers every job and matrix leg of that run that names the environment. | |
| 215 | +- Reviewers get a notification and an email (Actions email settings apply), the environment shows under **Deployments waiting for you** on [Today](/docs/agents/today), and the run page shows a **Review deployments** box with each environment they may approve, a comment and **Approve and deploy** or **Reject**. | |
| 216 | +- Only a person can approve, signed in to the web UI or with a personal token that has the `write` scope. Agents, a workflow's `GITHUB_TOKEN` and sandbox tokens can't, even when an agent's account is listed by mistake. Being listed is enough; a reviewer with read access may approve. That access is checked again when they decide, and it has to be access they were given (as the owner, a collaborator or an organization member): on a public repository, a reviewer removed from it can't approve, even though they can still read it. | |
| 217 | +- A job waits at most 30 days. After that it fails with `The deployment to production wasn't approved within 30 days.` | |
| 218 | + | |
| 219 | +### Prevent self-review | |
| 220 | + | |
| 221 | +With `prevent_self_review`, these people can't approve a run, even when they're listed: | |
| 222 | + | |
| 223 | +- whoever started the run, | |
| 224 | +- whoever started an earlier attempt of a re-run, | |
| 225 | +- the person who owns the agent that started it. | |
| 226 | + | |
| 227 | +This compares accounts, not logins, so renaming an account doesn't get around it. | |
| 228 | + | |
| 229 | +They see why on the run page, and the API answers `403 production doesn't let whoever started a run approve it`. | |
| 230 | + | |
| 231 | +### Wait timers | |
| 232 | + | |
| 233 | +A wait timer delays the job by that many minutes, counted from when it became ready to start. With reviewers too, the job starts once someone approved and the timer has passed, whichever comes last. Nobody can skip a timer. | |
| 234 | + | |
| 235 | +### Re-runs | |
| 236 | + | |
| 237 | +A re-run is a new run: its jobs wait again and need a new approval. The timer starts again too. | |
| 238 | + | |
| 239 | +### Review with the API | |
| 240 | + | |
| 241 | +```sh | |
| 242 | +# Environments the run waits on, and whether you may approve them | |
| 243 | +curl https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/pending_deployments \ | |
| 244 | + -H "Authorization: Bearer dfy_..." | |
| 245 | + | |
| 246 | +# Approve (or "rejected"); returns the deployments of the jobs that were waiting | |
| 247 | +curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/pending_deployments \ | |
| 248 | + -H "Authorization: Bearer dfy_..." \ | |
| 249 | + -H "Content-Type: application/json" \ | |
| 250 | + -d '{"environment_ids": [1], "state": "approved", "comment": "Ship it"}' | |
| 251 | + | |
| 252 | +# Past decisions on the run | |
| 253 | +curl https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/approvals \ | |
| 254 | + -H "Authorization: Bearer dfy_..." | |
| 255 | +``` | |
| 256 | + | |
| 257 | +A pending deployment looks like GitHub's: | |
| 258 | + | |
| 259 | +```json | |
| 260 | +[ | |
| 261 | + { | |
| 262 | + "environment": { "id": 1, "node_id": "...", "name": "production", "url": "...", "html_url": "..." }, | |
| 263 | + "wait_timer": 10, | |
| 264 | + "wait_timer_started_at": "2026-10-13T09:30:00Z", | |
| 265 | + "current_user_can_approve": true, | |
| 266 | + "reviewers": [{ "type": "User", "reviewer": { "login": "alice", "id": 12345, "type": "User" } }] | |
| 267 | + } | |
| 268 | +] | |
| 269 | +``` | |
| 270 | + | |
| 271 | +| Error | Meaning | | |
| 272 | +|---|---| | |
| 273 | +| `422 environment 1 isn't waiting for a review in this run` | Nothing in the run waits on that environment, or someone already decided. | | |
| 274 | +| `403 you aren't a required reviewer for production` | Only listed reviewers may decide. | | |
| 275 | +| `403 only people can review deployments, not agents` | Agents never approve deployments. | | |
| 276 | +| `403 reviewing deployments needs a token that may write` | Use a token with the `write` scope. | | |
| 277 | +| `403 only people with access to this repository review its deployments` | The reviewer was removed from the repository after being listed. Add them back, or change the environment's reviewers. | | |
| 278 | + | |
| 279 | +Each approval and rejection is in the audit log as `deployment.approve` or `deployment.reject`, with the comment. | |
| 280 | + | |
| 194 | 281 | ## Environment secrets and variables |
| 195 | 282 | |
| 196 | 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. |
| @@ -342,11 +429,11 @@ | ||
| 342 | 429 | |
| 343 | 430 | ### What happens when the job starts |
| 344 | 431 | |
| 345 | -When a hosted or self-hosted runner starts a job with `environment:`, Diffinery: | |
| 432 | +When a job with `environment:` is ready to run, Diffinery evaluates `name` once, and that name is the one the job deploys to. If the environment has required reviewers or a wait timer, the job [waits for them](#required-reviewers-and-wait-timers) first. When a hosted or self-hosted runner then starts the job, Diffinery: | |
| 346 | 433 | |
| 347 | -1. Evaluates `name` and `url`. | |
| 434 | +1. Evaluates `url`. | |
| 348 | 435 | 2. Creates the environment if it doesn't exist (with no branch policy). |
| 349 | -3. Checks the branch policy against the run's ref, and fails the job if the ref isn't allowed. | |
| 436 | +3. Checks the branch policy against the run's ref, and fails the job if the ref isn't allowed. A job whose environment needs an approval it didn't get fails with `The environment production requires approval this job didn't get. Re-run the job to ask for it.` (This happens when someone adds reviewers while the job is already on its way to a runner.) | |
| 350 | 437 | 4. 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). |
| 351 | 438 | 5. 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`. |
| 352 | 439 | 6. Passes the environment's variables to the job, and its secrets when [the rules](#who-receives-environment-secrets) allow it. |
| @@ -678,12 +765,15 @@ | ||
| 678 | 765 | | `GET /repos/{owner}/{repo}/environments/{env}/variables` and `.../variables/{name}` | write | |
| 679 | 766 | | `POST /repos/{owner}/{repo}/environments/{env}/variables` | admin, people only | |
| 680 | 767 | | `PATCH` and `DELETE /repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin, people only | |
| 768 | +| `GET /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments` | read | | |
| 769 | +| `POST /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments` | a listed reviewer (people only, `write` token) | | |
| 770 | +| `GET /repos/{owner}/{repo}/actions/runs/{run_id}/approvals` | read | | |
| 681 | 771 | |
| 682 | 772 | The 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 | 773 | |
| 684 | 774 | ## Webhooks |
| 685 | 775 | |
| 686 | -Repository 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). | |
| 776 | +Repository webhooks receive three 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`, `deployment_status` and `deployment_review` in the webhook's `events`. See [Webhooks](/docs/repositories/webhooks). | |
| 687 | 777 | |
| 688 | 778 | ### deployment |
| 689 | 779 | |
| @@ -733,6 +823,10 @@ | ||
| 733 | 823 | |
| 734 | 824 | The `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 | 825 | |
| 826 | +### deployment_review | |
| 827 | + | |
| 828 | +Sent when a run starts waiting on an environment's required reviewers (`requested`), and when a reviewer decides (`approved` or `rejected`). The payload has `environment`, `environment_id`, `comment`, `workflow_run` (`id`, `run_number`, `name`, `display_title`, `head_sha`, `head_branch`, `html_url`), `reviewers`, and `requestor` (requested) or `approver` (approved and rejected). | |
| 829 | + | |
| 736 | 830 | Workflows 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 | 831 | |
| 738 | 832 | ## Agents and environments |
| @@ -754,19 +848,21 @@ | ||
| 754 | 848 | |
| 755 | 849 | | Action | When | |
| 756 | 850 | |---|---| |
| 757 | -| `environment.update` | An environment is created or updated with `PUT` (with the new `deployment_branch_policy`). | | |
| 851 | +| `environment.update` | An environment is created or updated with `PUT` or in Settings (with the new branch policy, reviewers, wait timer and self-review setting). | | |
| 758 | 852 | | `environment.delete` | An environment is deleted. | |
| 759 | 853 | | `environment.branch_policy.create` | A branch or tag pattern is added. | |
| 760 | 854 | | `environment.branch_policy.delete` | A pattern is removed. | |
| 761 | 855 | | `environment.secret.set` | An environment secret is created or replaced (name only). | |
| 762 | 856 | | `environment.secret.delete` | An environment secret is deleted. | |
| 857 | +| `deployment.approve`, `deployment.reject` | A required reviewer approves or rejects a run's environments (with the comment). | | |
| 763 | 858 | |
| 764 | 859 | Environment variable changes aren't recorded. See [Audit log](/docs/security/audit-log). |
| 765 | 860 | |
| 766 | 861 | ## What isn't supported |
| 767 | 862 | |
| 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. | |
| 863 | +- **Custom deployment protection rules** (GitHub Apps that gate deployments) and **team reviewers**. Diffinery has no teams; list people. | |
| 864 | +- **Admin bypass.** Nobody skips an environment's rules. | |
| 865 | +- **Web UI for branch patterns, environment secrets and variables.** Settings → Environments edits the protection rules; the rest is managed with the REST API. | |
| 770 | 866 | - **Reading or updating a single branch pattern.** You can list, add and delete patterns, but not `GET` or `PUT` one by id. |
| 771 | 867 | - **Sealed-box secrets.** Send `value`, not `encrypted_value`. |
| 772 | 868 | - **Organization-level environments and environment secrets.** Environments belong to one repository. |
| @@ -776,9 +872,10 @@ | ||
| 776 | 872 | |
| 777 | 873 | | Area | GitHub | Diffinery | |
| 778 | 874 | |---|---|---| |
| 779 | -| Managing environments | Web UI and API | API only | | |
| 780 | -| Required reviewers, wait timers | Supported | Refused with `422` | | |
| 781 | -| Admin bypass | Admins can bypass protection rules | Nobody bypasses the branch policy; `can_admins_bypass` is always `true` but has no effect | | |
| 875 | +| Managing environments | Web UI and API | Protection rules in Settings → Environments and the API; patterns, secrets and variables with the API | | |
| 876 | +| Required reviewers | People and teams | People only, never agents; an agent or a job token can't approve | | |
| 877 | +| Prevent self-review | Covers whoever triggered the run | Also covers earlier attempts' starters and the owner of an agent that started it | | |
| 878 | +| Admin bypass | Admins can bypass protection rules | Nobody bypasses them; `can_admins_bypass` is `false` | | |
| 782 | 879 | | Default branch policy | All branches | All refs, until the first secret is added; then protected branches only | |
| 783 | 880 | | Protected branches | Branches with protection rules | Branches matched by a branch protection rule; the default branch counts only when a rule matches it | |
| 784 | 881 | | Pull request runs | Fork pull requests get no secrets | No `pull_request` run gets environment secrets | |
| @@ -800,7 +897,8 @@ | ||
| 800 | 897 | | `403 agents cannot manage secrets` | Use a person's token. | |
| 801 | 898 | | `403 agents cannot manage variables` | Agents can't change environment variables. Use a person's token. | |
| 802 | 899 | | `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. | |
| 803 | -| `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. | | |
| 900 | +| A job stays **Waiting for approval** | The environment has required reviewers: one of them approves on the run page or with `pending_deployments`. With a wait timer, the job starts when it ends. | | |
| 901 | +| `422 reviewers[0]: no such person with access to this repository` | GitHub's numeric user ids don't carry over. Use `{"type": "User", "login": "..."}` with a Diffinery login. | | |
| 804 | 902 | | `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. | |
| 805 | 903 | | `422 enable custom_branch_policies on the environment first` | Set `custom_branch_policies: true` with `PUT` before adding patterns. | |
| 806 | 904 | | `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). | |
modifiedapps/diffinery/docs/actions/workflow-syntax.md+2−2View file
| @@ -238,7 +238,7 @@ | ||
| 238 | 238 | | `needs` | Supported | Jobs that must finish first. | |
| 239 | 239 | | `if` | Supported | Condition evaluated before the job starts. | |
| 240 | 240 | | `runs-on` | Supported | Hosted Linux labels or self-hosted labels. May use `matrix`; evaluated for each matrix combination. `group` is ignored. | |
| 241 | -| `environment` | Supported | The deployment environment: its branch policy, secrets and variables apply, and a deployment is recorded. Required reviewers and wait timers aren't supported. See [environment](#environment). | | |
| 241 | +| `environment` | Supported | The deployment environment: its branch policy, required reviewers, wait timer, secrets and variables apply, and a deployment is recorded. See [environment](#environment). | | |
| 242 | 242 | | `outputs` | Supported | Values passed to dependent jobs through `needs`. | |
| 243 | 243 | | `env` | Supported | Environment variables for every step of the job. | |
| 244 | 244 | | `defaults` | Supported | `defaults.run` for the job's `run` steps. | |
| @@ -387,7 +387,7 @@ | ||
| 387 | 387 | |
| 388 | 388 | Differences from GitHub: |
| 389 | 389 | |
| 390 | -- Required reviewers, wait timers and other protection rules aren't supported, so a job never waits for approval. Creating an environment with them through the API fails with `required reviewers and wait timers are not supported yet`. | |
| 390 | +- Required reviewers are people only (Diffinery has no teams), nobody bypasses the rules, and custom deployment protection rules aren't supported. See [Required reviewers and wait timers](/docs/actions/environments#required-reviewers-and-wait-timers). | |
| 391 | 391 | - `url` can't use step outputs. |
| 392 | 392 | - The environment doesn't change the job token's permissions. To create deployment statuses yourself, give the job `deployments: write`. |
| 393 | 393 |
modifiedapps/diffinery/docs/agents/today.md+6−0View file
| @@ -26,6 +26,10 @@ | ||
| 26 | 26 | |
| 27 | 27 | A pull request opened by an agent you own is listed here too when it is waiting for a person: you are usually the person who approves it. |
| 28 | 28 | |
| 29 | +### Deployments waiting for you | |
| 30 | + | |
| 31 | +Workflow runs whose jobs wait for you as a required reviewer of an environment, newest first, linking to the run page where you approve or reject them. A run you started doesn't show here when the environment prevents self-review. See [Required reviewers and wait timers](/docs/actions/environments#required-reviewers-and-wait-timers). | |
| 32 | + | |
| 29 | 33 | ### Agents at work |
| 30 | 34 | |
| 31 | 35 | Open pull requests from agents that you own or that your organizations own, each with where it stands: |
| @@ -101,6 +105,7 @@ | ||
| 101 | 105 | "landed": [], |
| 102 | 106 | "releases": [], |
| 103 | 107 | "tasks": [], |
| 108 | + "deployments_waiting": [], | |
| 104 | 109 | "failures": [] |
| 105 | 110 | } |
| 106 | 111 | ``` |
| @@ -114,6 +119,7 @@ | ||
| 114 | 119 | | `landed[]` | Pull requests merged since `since`, with `merged_by` and `merged_at` | |
| 115 | 120 | | `releases[]` | Releases published since `since` | |
| 116 | 121 | | `failures[]` | Failed runs since `since`, with `workflow`, `ref`, `event`, `actor` and `pull_request` | |
| 122 | +| `deployments_waiting[]` | Runs waiting for you as an environment's required reviewer, with `environment`, `run_id`, `workflow`, `ref`, `actor` and `waiting_since` | | |
| 117 | 123 | | `tasks[]` | Your agent tasks that are running or changed since `since`, with `state`, `agent` and `pull_request` (an agent's token: the tasks it runs) | |
| 118 | 124 | |
| 119 | 125 | Any token works (a `read` token is enough). A token limited to repositories sees only those repositories. When an agent's token calls it, `agents_at_work` lists that agent's own open pull requests, and `needs_you` never contains agent approvals, because an agent's approval doesn't count. |
modifiedapps/diffinery/docs/migrate-from-github.md+3−3View file
| @@ -470,7 +470,7 @@ | ||
| 470 | 470 | - **Secret values** of any kind. Set them again yourself (see [Set secret values](#set-secret-values)). |
| 471 | 471 | - **Accounts.** No Diffinery accounts are created for GitHub users. See [People: linked accounts and mannequins](#people-linked-accounts-and-mannequins). |
| 472 | 472 | - **Reactions**, wikis, projects, discussions, GitHub Pages, packages, webhooks, deploy keys, collaborators and teams, repository rulesets, Actions run history, and GitHub Apps. |
| 473 | -- **Required reviewers and wait timers** on environments. | |
| 473 | +- **Required reviewers and wait timers** on environments. Set them again under Settings → Environments, with Diffinery people (see [Required reviewers and wait timers](/docs/actions/environments#required-reviewers-and-wait-timers)). | |
| 474 | 474 | - **Auto-merge** on open pull requests. Turn it on again on Diffinery (see [Auto-merge](/docs/collaboration/pull-requests#auto-merge)). |
| 475 | 475 | - **Pull request heads whose fork was deleted on GitHub.** The pull request is imported but has no head commit (`3 pull requests have no head commit (their fork was deleted on GitHub): #12, #15, #40`). |
| 476 | 476 | - **Anything a GitHub token couldn't read**, listed under **Warnings**. |
| @@ -761,7 +761,7 @@ | ||
| 761 | 761 | -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}' |
| 762 | 762 | ``` |
| 763 | 763 | |
| 764 | -Jobs with `environment: production` then get these secrets and variables. When an environment without a branch policy gets its first secret, it is limited to protected branches, so add a [branch protection](/docs/repositories/branch-protection) rule for the branch you deploy from (or set the environment's branch policy). Required reviewers and wait timers are not supported yet; the API refuses them. See [Environments](/docs/actions/environments). | |
| 764 | +Jobs with `environment: production` then get these secrets and variables. When an environment without a branch policy gets its first secret, it is limited to protected branches, so add a [branch protection](/docs/repositories/branch-protection) rule for the branch you deploy from (or set the environment's branch policy). Add required reviewers and a wait timer under Settings → Environments. See [Environments](/docs/actions/environments). | |
| 765 | 765 | |
| 766 | 766 | ### Variables |
| 767 | 767 | |
| @@ -819,7 +819,7 @@ | ||
| 819 | 819 | | Relies on `GITHUB_TOKEN` writing (comments, labels, statuses, releases, pushes) without a `permissions:` block | Repositories created on Diffinery, imported ones included, give the job token GitHub's restricted default, `contents` and `packages` read. Add a `permissions:` block (for example `contents: write`, `issues: write`, `pull-requests: write`) to the workflow or job, or have an admin set the repository default to write. Each endpoint needs its permission, as on GitHub; a missing one gets `403 Resource not accessible by integration`. See [Workflow permissions](/docs/actions/workflow-permissions). | |
| 820 | 820 | | Pushes commits or tags using `GITHUB_TOKEN` | Works with `permissions: contents: write`, as on GitHub, within branch protection. The token can't change files under `.github/workflows/`, and a push made with it starts no new `push` workflows. | |
| 821 | 821 | | Opens or approves pull requests using `GITHUB_TOKEN` | Refused until a repository admin allows it (`can_approve_pull_request_reviews` in the workflow permissions setting, off by default, as GitHub's **Allow GitHub Actions to create and approve pull requests**). Approvals from the token never count toward required approvals. | |
| 822 | -| Uses environments (`environment: production`) | The importer creates the environments and their variables; set their secrets with the API (see [Environments and their secrets](#environments-and-their-secrets)). Required reviewers and wait timers are not supported yet. | | |
| 822 | +| Uses environments (`environment: production`) | The importer creates the environments and their variables; set their secrets with the API (see [Environments and their secrets](#environments-and-their-secrets)). Required reviewers and wait timers don't come across; set them again under Settings → Environments. | | |
| 823 | 823 | | Runs on `release` or `repository_dispatch` | Works: publishing a release on Diffinery starts `on: release` workflows, and `POST /api/v3/repos/acme/website/dispatches` starts `on: repository_dispatch` workflows. | |
| 824 | 824 | | Checks out Git LFS files (`lfs: true`) | Works as on GitHub. See [Git LFS](/docs/repositories/git-lfs). | |
| 825 | 825 | | Publishes images to `ghcr.io` | Use the registry at `diffinery.com` instead. See [Container registry](/docs/packages/container-registry). | |
modifiedapps/diffinery/docs/reference/rest-api.md+2−2View file
| @@ -535,7 +535,7 @@ | ||
| 535 | 535 | ### Deployments and environments |
| 536 | 536 | |
| 537 | 537 | - **Deployments:** create, list, get and delete; deployment statuses: create, list and get. A deployment without `required_contexts` needs every check and status on the commit to have passed, as on GitHub (those reported by agents are not counted); send `"required_contexts": []` to skip the check. A deployment whose contexts haven't passed is refused with `409`. |
| 538 | -- **Environments:** list, get, create or update (`PUT /environments/{name}`), delete; deployment branch policies (`protected_branches` or `custom_branch_policies` with branch and tag patterns); environment secrets (names only, never values) and variables. Changing environments, their branch policies and secrets takes admin access, and agents can't do it. Required reviewers and wait timers are refused with `422 required reviewers and wait timers are not supported yet`. | |
| 538 | +- **Environments:** list, get, create or update (`PUT /environments/{name}`), delete; deployment branch policies (`protected_branches` or `custom_branch_policies` with branch and tag patterns); environment secrets (names only, never values) and variables. Changing environments, their branch policies and secrets takes admin access, and agents can't do it. Required reviewers (people) and wait timers hold jobs before they start; pending deployments (`GET` and `POST /actions/runs/{run_id}/pending_deployments`) and `GET /actions/runs/{run_id}/approvals` work as on GitHub. | |
| 539 | 539 | - An environment with no branch policy is limited to protected branches when its first secret is added. Environment secrets never go to `refs/pull/*` runs. Runs started by agents get them only when the environment allows protected branches only. Tags pushed by agents never match an environment's tag patterns. |
| 540 | 540 | - A workflow job with `environment:` creates a deployment, gets that environment's secrets and variables, and is refused if the environment's branch policy doesn't allow the ref. |
| 541 | 541 | |
| @@ -633,7 +633,7 @@ | ||
| 633 | 633 | |
| 634 | 634 | - GraphQL (`/api/graphql`). |
| 635 | 635 | - Setting secrets with a sealed-box `encrypted_value`; send `value` instead. |
| 636 | -- Environment protection rules: required reviewers and wait timers. | |
| 636 | +- Team reviewers and custom deployment protection rules on environments. | |
| 637 | 637 | - Forks, and team reviewers. |
| 638 | 638 | - Code search and commit search. Search qualifiers for stars, forks and languages are accepted but ignored. |
| 639 | 639 |
modifiedapps/diffinery/src/agentic/today-repo.ts+57−1View file
| @@ -69,11 +69,32 @@ | ||
| 69 | 69 | at: string; |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | +/** A run's jobs waiting on an environment's required reviewers. */ | |
| 73 | +export interface TodayDeployment { | |
| 74 | + runId: string; | |
| 75 | + runNumber: number; | |
| 76 | + workflow: string; | |
| 77 | + title: string; | |
| 78 | + ref: string; | |
| 79 | + environment: string; | |
| 80 | + /** People listed as the environment's required reviewers. */ | |
| 81 | + reviewerIds: string[]; | |
| 82 | + /** | |
| 83 | + * Account ids that can't approve because the environment prevents self-review: who started the run | |
| 84 | + * and its earlier attempts. When one of them is an agent, its owner can't either (see classify). | |
| 85 | + */ | |
| 86 | + selfBlocked: string[]; | |
| 87 | + actor: TodayPerson; | |
| 88 | + since: string; | |
| 89 | +} | |
| 90 | + | |
| 72 | 91 | export interface RepoToday { |
| 73 | 92 | pulls: TodayPull[]; |
| 74 | 93 | merged: TodayMerged[]; |
| 75 | 94 | releases: TodayRelease[]; |
| 76 | 95 | failedRuns: TodayRun[]; |
| 96 | + /** Absent in summaries from before environment reviewers existed. */ | |
| 97 | + deployments?: TodayDeployment[]; | |
| 77 | 98 | } |
| 78 | 99 | |
| 79 | 100 | /** At most this many open pull requests per repository (most recently updated first). */ |
| @@ -203,5 +224,40 @@ | ||
| 203 | 224 | return { id: x.id, runNumber: x.run_number, workflow: x.workflow_name, title: x.display_title, ref: x.ref, event: x.event, actor, conclusion: x.conclusion, prNumber: x.pr_number, at: x.updated_at }; |
| 204 | 225 | }); |
| 205 | 226 | |
| 206 | - return { pulls, merged, releases, failedRuns }; | |
| 227 | + const parsePerson = (json: string): TodayPerson & { onBehalfOf?: string } => { | |
| 228 | + try { | |
| 229 | + const a = JSON.parse(json) as Partial<TodayPerson> & { onBehalfOf?: string }; | |
| 230 | + return { id: a.id ?? "", login: a.login ?? "unknown", kind: a.kind ?? "user", onBehalfOf: a.onBehalfOf }; | |
| 231 | + } catch { | |
| 232 | + return { id: "", login: "unknown", kind: "system" }; | |
| 233 | + } | |
| 234 | + }; | |
| 235 | + const deployments = sql | |
| 236 | + .exec<{ run_id: string; environment: string; since: string; run_number: number; workflow_name: string; display_title: string; ref: string; actor: string; prior_actors: string | null; reviewers: string | null; prevent_self_review: number }>( | |
| 237 | + `SELECT j.run_id, e.name AS environment, MIN(j.gate_since) AS since, r.run_number, r.workflow_name, r.display_title, r.ref, r.actor, r.prior_actors, e.reviewers, e.prevent_self_review | |
| 238 | + FROM jobs j JOIN runs r ON r.id = j.run_id JOIN environments e ON e.name = j.environment COLLATE NOCASE | |
| 239 | + WHERE j.status = 'approval' AND e.reviewers IS NOT NULL | |
| 240 | + AND NOT EXISTS (SELECT 1 FROM deployment_reviews d WHERE d.run_id = j.run_id AND d.environment = e.name COLLATE NOCASE) | |
| 241 | + GROUP BY j.run_id, e.name ORDER BY since DESC LIMIT ?`, | |
| 242 | + MAX_RECENT, | |
| 243 | + ) | |
| 244 | + .toArray() | |
| 245 | + .map((x): TodayDeployment => { | |
| 246 | + const actor = parsePerson(x.actor); | |
| 247 | + const self = x.prevent_self_review ? [actor.id, ...((x.prior_actors ? JSON.parse(x.prior_actors) : []) as string[])].filter((l): l is string => !!l) : []; | |
| 248 | + return { | |
| 249 | + runId: x.run_id, | |
| 250 | + runNumber: x.run_number, | |
| 251 | + workflow: x.workflow_name, | |
| 252 | + title: x.display_title, | |
| 253 | + ref: x.ref, | |
| 254 | + environment: x.environment, | |
| 255 | + reviewerIds: (JSON.parse(x.reviewers!) as Array<{ id: string }>).map((r) => r.id), | |
| 256 | + selfBlocked: [...new Set(self)], | |
| 257 | + actor: { id: actor.id, login: actor.login, kind: actor.kind }, | |
| 258 | + since: x.since, | |
| 259 | + }; | |
| 260 | + }); | |
| 261 | + | |
| 262 | + return { pulls, merged, releases, failedRuns, deployments }; | |
| 207 | 263 | } |
modifiedapps/diffinery/src/agentic/today.ts+17−4View file
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | import type { Account, Directory, Repo } from "../lib/directory.js"; |
| 5 | 5 | import { repoStub } from "../http/common.js"; |
| 6 | 6 | import { atLeast } from "../repo/types.js"; |
| 7 | -import type { RepoToday, TodayMerged, TodayPerson, TodayPull, TodayRelease, TodayRun } from "./today-repo.js"; | |
| 7 | +import type { RepoToday, TodayDeployment, TodayMerged, TodayPerson, TodayPull, TodayRelease, TodayRun } from "./today-repo.js"; | |
| 8 | 8 | |
| 9 | 9 | /** |
| 10 | 10 | * Today: one person's morning view across every repository they can see. |
| @@ -58,6 +58,8 @@ | ||
| 58 | 58 | landed: Array<{ repo: TodayRepo; pull: TodayMerged }>; |
| 59 | 59 | releases: Array<{ repo: TodayRepo; release: TodayRelease }>; |
| 60 | 60 | failures: Array<{ repo: TodayRepo; run: TodayRun }>; |
| 61 | + /** Deployments waiting for the viewer as one of an environment's required reviewers. */ | |
| 62 | + deployments: Array<{ repo: TodayRepo; deployment: TodayDeployment }>; | |
| 61 | 63 | /** Agent tasks you started (an agent: the tasks it runs) that are running or changed since `since`. */ |
| 62 | 64 | tasks: MyTask[]; |
| 63 | 65 | } |
| @@ -66,6 +68,8 @@ | ||
| 66 | 68 | me: { id: string; login: string }; |
| 67 | 69 | /** Agents the viewer or their organizations own (and the viewer itself, when an agent asks). */ |
| 68 | 70 | agentIds: Set<string>; |
| 71 | + /** Agents the viewer owns themselves: a run one of them started is the viewer's own for prevent_self_review. */ | |
| 72 | + ownAgentIds?: Set<string>; | |
| 69 | 73 | /** Repositories where the viewer can approve (write or more). */ |
| 70 | 74 | writable: Set<string>; |
| 71 | 75 | repos: Array<{ repo: TodayRepo; data: RepoToday }>; |
| @@ -99,7 +103,12 @@ | ||
| 99 | 103 | const landed: Today["landed"] = []; |
| 100 | 104 | const releases: Today["releases"] = []; |
| 101 | 105 | const failures: Today["failures"] = []; |
| 106 | + const deployments: Today["deployments"] = []; | |
| 102 | 107 | for (const { repo, data } of input.repos) { |
| 108 | + for (const deployment of data.deployments ?? []) { | |
| 109 | + const own = deployment.selfBlocked.some((id) => id === input.me.id || input.ownAgentIds?.has(id)); | |
| 110 | + if (deployment.reviewerIds.includes(input.me.id) && !own) deployments.push({ repo, deployment }); | |
| 111 | + } | |
| 103 | 112 | for (const pull of data.pulls) { |
| 104 | 113 | const mine = pull.author.id === input.me.id; |
| 105 | 114 | let need: NeedsKind | null = null; |
| @@ -124,7 +133,8 @@ | ||
| 124 | 133 | landed.sort(byNewest((x) => x.pull.mergedAt)); |
| 125 | 134 | releases.sort(byNewest((x) => x.release.publishedAt)); |
| 126 | 135 | failures.sort(byNewest((x) => x.run.at)); |
| 127 | - return { needsYou, agentsAtWork, landed: landed.slice(0, 30), releases: releases.slice(0, 20), failures: failures.slice(0, 20) }; | |
| 136 | + deployments.sort(byNewest((x) => x.deployment.since)); | |
| 137 | + return { needsYou, agentsAtWork, landed: landed.slice(0, 30), releases: releases.slice(0, 20), failures: failures.slice(0, 20), deployments: deployments.slice(0, 20) }; | |
| 128 | 138 | } |
| 129 | 139 | |
| 130 | 140 | /** The repositories Today visits: not archived, active in the last ACTIVE_DAYS, most recent first. */ |
| @@ -158,7 +168,9 @@ | ||
| 158 | 168 | const allowed = opts.repoIds ? visible.filter((r) => opts.repoIds!.includes(r.id)) : visible; |
| 159 | 169 | const { repos, skipped } = pickRepos(allowed, activity, now); |
| 160 | 170 | const owners = me.kind === "agent" ? [] : [me.id, ...orgs.map((o) => o.id)]; |
| 161 | - const agentIds = new Set((await dir.agentsOwnedBy(owners)).map((a) => a.id)); | |
| 171 | + const agents = await dir.agentsOwnedBy(owners); | |
| 172 | + const agentIds = new Set(agents.map((a) => a.id)); | |
| 173 | + const ownAgentIds = new Set(agents.filter((a) => a.owner_id === me.id).map((a) => a.id)); | |
| 162 | 174 | if (me.kind === "agent") agentIds.add(me.id); |
| 163 | 175 | |
| 164 | 176 | const tasks = tasksFor(env, dir, me, { repoIds: opts.repoIds, since, activeOrSince: true, limit: 20 }).catch((e) => { |
| @@ -186,7 +198,7 @@ | ||
| 186 | 198 | since, |
| 187 | 199 | repositories: answered.length, |
| 188 | 200 | incomplete: skipped > 0 || answered.length < repos.length, |
| 189 | - ...classify({ me, agentIds, writable, repos: answered }), | |
| 201 | + ...classify({ me, agentIds, ownAgentIds, writable, repos: answered }), | |
| 190 | 202 | tasks: await tasks, |
| 191 | 203 | }; |
| 192 | 204 | } |
| @@ -242,6 +254,7 @@ | ||
| 242 | 254 | landed: t.landed.map((x) => ({ repository: x.repo.fullName, number: x.pull.number, title: x.pull.title, html_url: `${base}${x.repo.url}/pull/${x.pull.number}`, user: person(x.pull.author), merged_by: x.pull.mergedBy, merged_at: x.pull.mergedAt })), |
| 243 | 255 | releases: t.releases.map((x) => ({ repository: x.repo.fullName, tag_name: x.release.tag, name: x.release.name, prerelease: x.release.prerelease, html_url: `${base}${x.repo.url}/releases/tag/${encodeURIComponent(x.release.tag)}`, author: person(x.release.author), published_at: x.release.publishedAt })), |
| 244 | 256 | tasks: t.tasks.map((x) => myTaskJson(base, x)), |
| 257 | + deployments_waiting: t.deployments.map((x) => ({ repository: x.repo.fullName, environment: x.deployment.environment, run_id: x.deployment.runId, run_number: x.deployment.runNumber, workflow: x.deployment.workflow, title: x.deployment.title, ref: x.deployment.ref, actor: person(x.deployment.actor), html_url: `${base}${x.repo.url}/actions/runs/${x.deployment.runId}`, waiting_since: x.deployment.since })), | |
| 245 | 258 | failures: t.failures.map((x) => ({ repository: x.repo.fullName, run_id: x.run.id, run_number: x.run.runNumber, workflow: x.run.workflow, title: x.run.title, ref: x.run.ref, event: x.run.event, conclusion: x.run.conclusion, html_url: `${base}${x.repo.url}/actions/runs/${x.run.id}`, actor: person(x.run.actor), pull_request: x.run.prNumber, completed_at: x.run.at })), |
| 246 | 259 | }; |
| 247 | 260 | } |
modifiedapps/diffinery/src/agentic/web.tsx+27−0View file
| @@ -82,6 +82,7 @@ | ||
| 82 | 82 | <Tile n={today.failures.length} label="failed runs" href="#landed" tone="red" /> |
| 83 | 83 | </div> |
| 84 | 84 | <NeedsYou items={today.needsYou} /> |
| 85 | + {today.deployments.length ? <Deployments items={today.deployments} /> : null} | |
| 85 | 86 | <Agents items={today.agentsAtWork} /> |
| 86 | 87 | {today.tasks.length ? <Tasks items={today.tasks} /> : null} |
| 87 | 88 | <SinceYesterday today={today} /> |
| @@ -130,6 +131,32 @@ | ||
| 130 | 131 | </div> |
| 131 | 132 | ); |
| 132 | 133 | |
| 134 | +const Deployments: FC<{ items: Today["deployments"] }> = ({ items }) => ( | |
| 135 | + <div class="box" id="deployments"> | |
| 136 | + <div class="head"> | |
| 137 | + <h2>{raw(ICONS.rocket)} Deployments waiting for you</h2> | |
| 138 | + <span class="muted small">You're a required reviewer for these environments</span> | |
| 139 | + </div> | |
| 140 | + <ul class="list"> | |
| 141 | + {items.map(({ repo, deployment: d }) => ( | |
| 142 | + <li> | |
| 143 | + <div class="grow"> | |
| 144 | + <a href={`${repo.url}/actions/runs/${d.runId}`}> | |
| 145 | + <b>{d.environment}</b>: {d.title || d.workflow} | |
| 146 | + </a> | |
| 147 | + <div class="muted small"> | |
| 148 | + {repo.fullName} · {d.workflow} #{d.runNumber} on {d.ref.replace(/^refs\/(heads|tags)\//, "")} · started by {d.actor.login} | |
| 149 | + </div> | |
| 150 | + </div> | |
| 151 | + <span class="muted small"> | |
| 152 | + <Time at={d.since} /> | |
| 153 | + </span> | |
| 154 | + </li> | |
| 155 | + ))} | |
| 156 | + </ul> | |
| 157 | + </div> | |
| 158 | +); | |
| 159 | + | |
| 133 | 160 | const NeedsYou: FC<{ items: NeedsItem[] }> = ({ items }) => ( |
| 134 | 161 | <div class="box" id="needs-you"> |
| 135 | 162 | <div class="head"> |
modifiedapps/diffinery/src/app.ts+1−0View file
| @@ -137,6 +137,7 @@ | ||
| 137 | 137 | status: "status", |
| 138 | 138 | deployment: "deployment", |
| 139 | 139 | deployment_status: "deployment_status", |
| 140 | + deployment_review: "deployment_review", | |
| 140 | 141 | repository_dispatch: "repository_dispatch", |
| 141 | 142 | audit: "audit", |
| 142 | 143 | }; |
modifiedapps/diffinery/src/docs/api-meta.ts+21−3View file
| @@ -1005,12 +1005,13 @@ | ||
| 1005 | 1005 | "PUT /repos/{owner}/{repo}/environments/{env}": { |
| 1006 | 1006 | summary: "Create or update an environment", |
| 1007 | 1007 | description: |
| 1008 | - "People only; agents can't change environments. Required reviewers and wait timers aren't supported: a non-zero `wait_timer` or any `reviewers` fails with 422, and `prevent_self_review` is ignored. Audited. See [Environments](/docs/actions/environments).", | |
| 1008 | + "People only; agents can't change environments. Required reviewers and a wait timer hold a job that names the environment before anything starts, until a reviewer approves and the timer has passed. Fields left out keep their values. Audited. See [Required reviewers and wait timers](/docs/actions/environments#required-reviewers-and-wait-timers).", | |
| 1009 | 1009 | access: "admin", |
| 1010 | 1010 | body: { |
| 1011 | 1011 | deployment_branch_policy: ["object", "`{ protected_branches, custom_branch_policies }` with exactly one true, or `null` to allow every ref."], |
| 1012 | - wait_timer: ["integer", "Must be 0 or absent."], | |
| 1013 | - reviewers: ["array", "Must be empty or absent."], | |
| 1012 | + wait_timer: ["integer", "Minutes a job waits before it deploys, 0 to 43200."], | |
| 1013 | + reviewers: ["array", "Up to 6 people with access, as `{type: \"User\", id}` (the numeric id the API shows) or `{type: \"User\", login}`. Never agents; Diffinery has no teams. `[]` or `null` removes them."], | |
| 1014 | + prevent_self_review: ["boolean", "Whoever started a run (or an earlier attempt, or owns the agent that did) can't approve it."], | |
| 1014 | 1015 | }, |
| 1015 | 1016 | }, |
| 1016 | 1017 | "DELETE /repos/{owner}/{repo}/environments/{env}": { summary: "Delete an environment", description: "Deletes its secrets and variables too. People only. Audited.", access: "admin", status: 204 }, |
| @@ -1160,6 +1161,23 @@ | ||
| 1160 | 1161 | access: "read", |
| 1161 | 1162 | }, |
| 1162 | 1163 | "GET /repos/{owner}/{repo}/actions/runs/{id}/jobs": { summary: "List a run's jobs", access: "read" }, |
| 1164 | + "GET /repos/{owner}/{repo}/actions/runs/{id}/pending_deployments": { | |
| 1165 | + summary: "List the environments a run waits on", | |
| 1166 | + description: "Environments whose required reviewers or wait timer hold the run's jobs, with `current_user_can_approve` for the caller. See [Required reviewers and wait timers](/docs/actions/environments#required-reviewers-and-wait-timers).", | |
| 1167 | + access: "read", | |
| 1168 | + }, | |
| 1169 | + "POST /repos/{owner}/{repo}/actions/runs/{id}/pending_deployments": { | |
| 1170 | + summary: "Approve or reject a run's pending deployments", | |
| 1171 | + description: | |
| 1172 | + "Only a listed reviewer, signed in or with a `write` token: never an agent, a workflow's token or a sandbox token (403). With `prevent_self_review`, not whoever started the run (403). One decision per environment and run counts; 422 when nothing waits. Approved jobs start once their wait timer has passed; rejected ones fail. Returns the deployments of the jobs that were waiting. Audited as `deployment.approve` or `deployment.reject`.", | |
| 1173 | + access: "read", | |
| 1174 | + body: { | |
| 1175 | + environment_ids: ["array", "Ids of the environments to decide.", true], | |
| 1176 | + state: ["string", "`approved` or `rejected`.", true], | |
| 1177 | + comment: ["string", "Shown with the decision and in the audit log."], | |
| 1178 | + }, | |
| 1179 | + }, | |
| 1180 | + "GET /repos/{owner}/{repo}/actions/runs/{id}/approvals": { summary: "List a run's deployment reviews", description: "Each reviewer's decision, with the environment and comment.", access: "read" }, | |
| 1163 | 1181 | "POST /repos/{owner}/{repo}/actions/runs/{id}/cancel": { summary: "Cancel a run", access: "write", status: 202 }, |
| 1164 | 1182 | "POST /repos/{owner}/{repo}/actions/runs/{id}/rerun": { summary: "Re-run a workflow run", description: "A workflow's `GITHUB_TOKEN` can't re-run workflows (403).", access: "write", status: 201 }, |
| 1165 | 1183 | "POST /repos/{owner}/{repo}/actions/runs/{id}/rerun-failed-jobs": { summary: "Re-run a run's failed jobs", description: "A workflow's `GITHUB_TOKEN` can't re-run workflows (403).", access: "write", status: 201 }, |
modifiedapps/diffinery/src/http/deployments.ts+75−12View file
| @@ -2,11 +2,13 @@ | ||
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | 3 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 4 | 4 | import { HttpError } from "../lib/util.js"; |
| 5 | -import { parsePayload, type Deployment, type DeploymentStatus, type Environment } from "../repo/deployments.js"; | |
| 5 | +import { parsePayload, type Deployment, type DeploymentReview, type DeploymentStatus, type Environment, type Reviewer } from "../repo/deployments.js"; | |
| 6 | +import type { PendingDeployment } from "../repo/actions.js"; | |
| 6 | 7 | import type { Caller } from "../repo/types.js"; |
| 7 | 8 | import { baseOf, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js"; |
| 8 | 9 | import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js"; |
| 9 | 10 | import { refuseAgent } from "./common.js"; |
| 11 | +import { resolveReviewers, reviewDeployments, viewerFor } from "./environment-reviews.js"; | |
| 10 | 12 | |
| 11 | 13 | /** Deployments, deployment statuses and environments, with environment secrets and variables (GitHub REST). */ |
| 12 | 14 | export const deployments = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| @@ -61,23 +63,57 @@ | ||
| 61 | 63 | }; |
| 62 | 64 | } |
| 63 | 65 | |
| 64 | -function environmentJson(c: AppContext, e: Environment) { | |
| 65 | - const name = encodeURIComponent(e.name); | |
| 66 | +const reviewerJson = (c: AppContext, r: Reviewer) => ({ type: "User", reviewer: ghUser(baseOf(c), { id: r.id, login: r.login, kind: "user" }) }); | |
| 67 | + | |
| 68 | +/** An environment as the REST API shows it inside other objects (pending deployments, approvals). */ | |
| 69 | +function environmentRef(c: AppContext, e: { id: number; name: string }) { | |
| 66 | 70 | return { |
| 67 | 71 | id: e.id, |
| 68 | 72 | node_id: nodeId("Environment", `${repoParams(c).owner}/${repoParams(c).repo}/${e.id}`), |
| 69 | 73 | name: e.name, |
| 70 | - url: `${api(c)}/environments/${name}`, | |
| 71 | - // Diffinery has no environments page yet; deployments show on the Actions runs that made them. | |
| 72 | - html_url: `${repoHtml(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/actions`, | |
| 74 | + url: `${api(c)}/environments/${encodeURIComponent(e.name)}`, | |
| 75 | + html_url: `${repoHtml(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/settings/environments/${encodeURIComponent(e.name)}`, | |
| 76 | + }; | |
| 77 | +} | |
| 78 | + | |
| 79 | +function environmentJson(c: AppContext, e: Environment) { | |
| 80 | + const p = e.protection; | |
| 81 | + const rules: Array<Record<string, unknown>> = []; | |
| 82 | + if (p.waitTimer > 0) rules.push({ id: e.id * 10 + 1, node_id: nodeId("WaitTimer", e.id), type: "wait_timer", wait_timer: p.waitTimer }); | |
| 83 | + if (p.reviewers.length) rules.push({ id: e.id * 10 + 2, node_id: nodeId("RequiredReviewers", e.id), type: "required_reviewers", prevent_self_review: p.preventSelfReview, reviewers: p.reviewers.map((r) => reviewerJson(c, r)) }); | |
| 84 | + if (e.policy) rules.push({ id: e.id * 10 + 3, node_id: nodeId("BranchPolicy", e.id), type: "branch_policy" }); | |
| 85 | + return { | |
| 86 | + ...environmentRef(c, e), | |
| 73 | 87 | created_at: isoSeconds(e.createdAt), |
| 74 | 88 | updated_at: isoSeconds(e.updatedAt), |
| 75 | - can_admins_bypass: true, | |
| 76 | - protection_rules: e.policy ? [{ id: e.id, node_id: nodeId("BranchPolicy", e.id), type: "branch_policy" }] : [], | |
| 89 | + // Nobody skips an environment's rules, admins included. | |
| 90 | + can_admins_bypass: false, | |
| 91 | + protection_rules: rules, | |
| 77 | 92 | deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null, |
| 78 | 93 | }; |
| 79 | 94 | } |
| 80 | 95 | |
| 96 | +function pendingJson(c: AppContext, p: PendingDeployment) { | |
| 97 | + return { | |
| 98 | + environment: environmentRef(c, p.environment), | |
| 99 | + wait_timer: p.waitTimer, | |
| 100 | + wait_timer_started_at: isoSeconds(p.waitTimerStartedAt), | |
| 101 | + current_user_can_approve: p.currentUserCanApprove, | |
| 102 | + reviewers: p.reviewers.map((r) => reviewerJson(c, r)), | |
| 103 | + }; | |
| 104 | +} | |
| 105 | + | |
| 106 | +function approvalJson(c: AppContext, r: DeploymentReview, envs: Environment[]) { | |
| 107 | + const e = envs.find((x) => x.name.toLowerCase() === r.environment.toLowerCase()); | |
| 108 | + return { | |
| 109 | + url: `${api(c)}/actions/runs/${r.runId}/approvals`, | |
| 110 | + environments: [environmentRef(c, e ?? { id: 0, name: r.environment })], | |
| 111 | + state: r.state, | |
| 112 | + user: ghUser(baseOf(c), { id: r.user.id, login: r.user.login, kind: "user" }), | |
| 113 | + comment: r.comment, | |
| 114 | + }; | |
| 115 | +} | |
| 116 | + | |
| 81 | 117 | /** Environment secrets and settings are sensitive: changes go to the audit log. */ |
| 82 | 118 | function audit(c: AppContext, caller: Caller, action: string, detail: Record<string, unknown>) { |
| 83 | 119 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action, target: `${repoParams(c).owner}/${repoParams(c).repo}`, detail })); |
| @@ -138,10 +174,18 @@ | ||
| 138 | 174 | return c.json(environmentJson(c, await stub.environment(envName(c)))); |
| 139 | 175 | }); |
| 140 | 176 | deployments.put(`${R}/environments/:env`, async (c) => { |
| 141 | - const { stub, caller } = await load(c, "admin"); | |
| 142 | - const b = await jsonBody<{ wait_timer?: number; reviewers?: unknown[] | null; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null; prevent_self_review?: boolean }>(c, true); | |
| 143 | - const e = await stub.upsertEnvironment(envName(c), b, caller); | |
| 144 | - audit(c, caller, "environment.update", { environment: e.name, deployment_branch_policy: b.deployment_branch_policy ?? null }); | |
| 177 | + const { stub, caller, repo } = await load(c, "admin"); | |
| 178 | + const b = await jsonBody<{ wait_timer?: unknown; reviewers?: unknown; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null; prevent_self_review?: unknown }>(c, true); | |
| 179 | + if (caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage environments"); | |
| 180 | + const reviewers = await resolveReviewers(c, repo, b.reviewers); | |
| 181 | + const e = await stub.upsertEnvironment(envName(c), { ...b, reviewers }, caller); | |
| 182 | + audit(c, caller, "environment.update", { | |
| 183 | + environment: e.name, | |
| 184 | + deployment_branch_policy: b.deployment_branch_policy ?? null, | |
| 185 | + ...(reviewers !== undefined ? { reviewers: (reviewers ?? []).map((r) => r.login) } : {}), | |
| 186 | + ...(b.wait_timer !== undefined ? { wait_timer: b.wait_timer } : {}), | |
| 187 | + ...(b.prevent_self_review !== undefined ? { prevent_self_review: b.prevent_self_review } : {}), | |
| 188 | + }); | |
| 145 | 189 | return c.json(environmentJson(c, e)); |
| 146 | 190 | }); |
| 147 | 191 | deployments.delete(`${R}/environments/:env`, async (c) => { |
| @@ -247,3 +291,22 @@ | ||
| 247 | 291 | audit(c, caller, "environment.variable.delete", { environment: envName(c), name: c.req.param("name").toUpperCase() }); |
| 248 | 292 | return c.body(null, 204); |
| 249 | 293 | }); |
| 294 | + | |
| 295 | +// ---------------------------------------------------------------- deployments waiting on reviewers (pending deployments) | |
| 296 | + | |
| 297 | +deployments.get(`${R}/actions/runs/:id/pending_deployments`, async (c) => { | |
| 298 | + const { stub, repo } = await load(c); | |
| 299 | + const viewer = await viewerFor(c, repo); | |
| 300 | + return c.json((await stub.pendingDeployments(c.req.param("id"), viewer)).map((x) => pendingJson(c, x))); | |
| 301 | +}); | |
| 302 | +deployments.post(`${R}/actions/runs/:id/pending_deployments`, async (c) => { | |
| 303 | + const l = await load(c); | |
| 304 | + const b = await jsonBody<{ environment_ids?: unknown; state?: unknown; comment?: unknown }>(c); | |
| 305 | + const out = await reviewDeployments(c, l, c.req.param("id"), b); | |
| 306 | + return c.json(out.deployments.map((d) => deploymentJson(c, d))); | |
| 307 | +}); | |
| 308 | +deployments.get(`${R}/actions/runs/:id/approvals`, async (c) => { | |
| 309 | + const { stub } = await load(c); | |
| 310 | + const [reviews, envs] = await Promise.all([stub.deploymentReviews(c.req.param("id")), stub.environments()]); | |
| 311 | + return c.json(reviews.map((r) => approvalJson(c, r, envs))); | |
| 312 | +}); |
addedapps/diffinery/src/http/environment-reviews.ts+109−0View file
| @@ -1,0 +1,109 @@ | ||
| 1 | +import type { AppContext } from "../auth/principal.js"; | |
| 2 | +import type { Account, Directory, Repo } from "../lib/directory.js"; | |
| 3 | +import { HttpError } from "../lib/util.js"; | |
| 4 | +import { MAX_REVIEW_COMMENT, MAX_REVIEWERS, type Reviewer } from "../repo/deployments.js"; | |
| 5 | +import { atLeast } from "../repo/types.js"; | |
| 6 | +import type { loadRepo } from "./common.js"; | |
| 7 | +import { numericId } from "./gh.js"; | |
| 8 | + | |
| 9 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Environment required reviewers (phase 3f): who may be one, and who may approve. | |
| 13 | + * Shared by the REST API and the web pages. | |
| 14 | + */ | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * Whether someone was given access to the repository: as its owner, a collaborator or a member of | |
| 18 | + * its organization. Reading a public repository doesn't count, so a reviewer removed from a public | |
| 19 | + * repository stops being one. | |
| 20 | + */ | |
| 21 | +export async function hasGrantedAccess(dir: Directory, repo: Repo, account: Account): Promise<boolean> { | |
| 22 | + return atLeast(await dir.roleFor(account, { ...repo, visibility: "private" }), "read"); | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** The signed-in person viewing a run, and whether they may still review its deployments. */ | |
| 26 | +export async function viewerFor(c: AppContext, repo: Repo): Promise<{ id: string; login: string; mayReview: boolean } | null> { | |
| 27 | + const p = c.get("principal"); | |
| 28 | + if (p.type !== "account") return null; | |
| 29 | + return { id: p.account.id, login: p.account.login, mayReview: p.account.kind === "user" && (await hasGrantedAccess(c.get("dir"), repo, p.account)) }; | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * An environment's `reviewers` as the REST API takes them (`[{type: "User", id}]`, where `id` is | |
| 34 | + * the numeric id Diffinery's API shows for a person, or `login` instead), resolved to people with | |
| 35 | + * at least read access. Never an agent; Diffinery has no teams. Undefined leaves them as they are. | |
| 36 | + */ | |
| 37 | +export async function resolveReviewers(c: AppContext, repo: Repo, raw: unknown): Promise<Reviewer[] | null | undefined> { | |
| 38 | + if (raw === undefined) return undefined; | |
| 39 | + if (raw === null) return null; | |
| 40 | + if (!Array.isArray(raw)) throw new HttpError(422, "reviewers must be a list of {type, id}"); | |
| 41 | + if (raw.length > MAX_REVIEWERS) throw new HttpError(422, `an environment can have at most ${MAX_REVIEWERS} required reviewers`); | |
| 42 | + const dir = c.get("dir"); | |
| 43 | + let candidates: Array<{ id: string; login: string; kind: string }> | null = null; | |
| 44 | + const people = async () => { | |
| 45 | + if (candidates) return candidates; | |
| 46 | + const list: Array<{ id: string; login: string; kind: string }> = (await dir.collaborators(repo.id)).map((x) => x.account); | |
| 47 | + if (repo.owner_kind === "org") list.push(...(await dir.orgMembers(repo.owner_id))); | |
| 48 | + else { | |
| 49 | + const owner = await dir.account(repo.owner_id); | |
| 50 | + if (owner) list.push(owner); | |
| 51 | + } | |
| 52 | + candidates = list; | |
| 53 | + return list; | |
| 54 | + }; | |
| 55 | + const out: Reviewer[] = []; | |
| 56 | + for (const [i, r] of raw.entries()) { | |
| 57 | + const at = `reviewers[${i}]`; | |
| 58 | + if (!r || typeof r !== "object") throw new HttpError(422, `${at} must be {type: "User", id}`); | |
| 59 | + const x = r as { type?: unknown; id?: unknown; login?: unknown }; | |
| 60 | + if (x.type === "Team") throw new HttpError(422, `${at}: Diffinery has no teams; list people as {type: "User", id}`); | |
| 61 | + if (x.type !== "User") throw new HttpError(422, `${at}.type must be "User"`); | |
| 62 | + let account = null; | |
| 63 | + if (typeof x.login === "string" && x.login) account = await dir.accountByLogin(x.login); | |
| 64 | + else if (typeof x.id === "number") { | |
| 65 | + const hit = (await people()).find((p) => numericId(p.id) === x.id); | |
| 66 | + account = hit ? await dir.account(hit.id) : null; | |
| 67 | + } | |
| 68 | + else if (typeof x.id === "string" && x.id) account = (await dir.account(x.id)) ?? (await dir.accountByLogin(x.id)); | |
| 69 | + if (!account) throw new HttpError(422, `${at}: no such person with access to this repository`); | |
| 70 | + if (account.kind !== "user") throw new HttpError(422, `${at}: ${account.login} is ${account.kind === "agent" ? "an agent; only people can be required reviewers" : "not a person"}`); | |
| 71 | + if (!(await hasGrantedAccess(dir, repo, account))) throw new HttpError(422, `${at}: ${account.login} has no access to this repository`); | |
| 72 | + if (!out.some((o) => o.id === account!.id)) out.push({ type: "User", id: account.id, login: account.login }); | |
| 73 | + } | |
| 74 | + return out; | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** | |
| 78 | + * Who is approving: only a person, in a browser session or with a personal token that may write. | |
| 79 | + * Never an agent, a workflow's GITHUB_TOKEN, a sandbox token or an import's token. | |
| 80 | + */ | |
| 81 | +export function reviewerFor(c: AppContext): { id: string; login: string; account: Account } { | |
| 82 | + const p = c.get("principal"); | |
| 83 | + if (p.type !== "account") throw new HttpError(403, "only people can review deployments, not workflow or sandbox tokens"); | |
| 84 | + if (p.account.kind !== "user") throw new HttpError(403, "only people can review deployments, not agents"); | |
| 85 | + if (p.via === "token" && (p.importId || !p.scopes.some((s) => s === "write" || s === "admin"))) throw new HttpError(403, "reviewing deployments needs a token that may write"); | |
| 86 | + return { id: p.account.id, login: p.account.login, account: p.account }; | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** Approve or reject environments a run waits on, as the person making the request; audited with the comment. */ | |
| 90 | +export async function reviewDeployments(c: AppContext, l: Loaded, runId: string, input: { environment_ids?: unknown; state?: unknown; comment?: unknown }) { | |
| 91 | + const reviewer = reviewerFor(c); | |
| 92 | + if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); | |
| 93 | + // Checked now, not only when the list was set: someone removed from the repository no longer decides. | |
| 94 | + if (!(await hasGrantedAccess(c.get("dir"), l.repo, reviewer.account))) throw new HttpError(403, "only people with access to this repository review its deployments"); | |
| 95 | + const ids = input.environment_ids; | |
| 96 | + if (!Array.isArray(ids) || !ids.length || ids.some((x) => typeof x !== "number" || !Number.isInteger(x))) throw new HttpError(422, "environment_ids must be a list of environment ids"); | |
| 97 | + if (input.state !== "approved" && input.state !== "rejected") throw new HttpError(422, 'state must be "approved" or "rejected"'); | |
| 98 | + if (input.comment !== undefined && typeof input.comment !== "string") throw new HttpError(422, "comment must be text"); | |
| 99 | + const comment = ((input.comment as string | undefined) ?? "").trim(); | |
| 100 | + if (comment.length > MAX_REVIEW_COMMENT) throw new HttpError(422, `comment is longer than ${MAX_REVIEW_COMMENT} characters`); | |
| 101 | + const out = await l.stub.reviewDeployments(runId, { environmentIds: ids as number[], state: input.state, comment }, { id: reviewer.id, login: reviewer.login, mayReview: true }); | |
| 102 | + c.executionCtx.waitUntil( | |
| 103 | + c | |
| 104 | + .get("dir") | |
| 105 | + .audit({ actorId: reviewer.id, actorLogin: reviewer.login, action: `deployment.${input.state === "approved" ? "approve" : "reject"}`, target: `${l.repo.owner_login}/${l.repo.name}`, detail: { run: runId, environments: out.reviews.map((r) => r.environment), comment } }) | |
| 106 | + .catch((e) => console.error("audit failed", e)), | |
| 107 | + ); | |
| 108 | + return out; | |
| 109 | +} |
modifiedapps/diffinery/src/http/gh.ts+2−0View file
| @@ -659,6 +659,8 @@ | ||
| 659 | 659 | const api = repoApi(base, owner, repo); |
| 660 | 660 | return { |
| 661 | 661 | ...j, |
| 662 | + // A job held by its environment's reviewers or wait timer is "waiting", as on GitHub. | |
| 663 | + status: j.status === "approval" ? "waiting" : j.status, | |
| 662 | 664 | run_id: j.runId, |
| 663 | 665 | run_url: `${api}/actions/runs/${j.runId}`, |
| 664 | 666 | run_attempt: run.attempt, |
modifiedapps/diffinery/src/mail/notify.ts+25−0View file
| @@ -213,9 +213,34 @@ | ||
| 213 | 213 | /** Web notifications and emails for one event from the events queue. */ |
| 214 | 214 | export async function notifyEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { |
| 215 | 215 | if (ev.type === "workflow_run") return workflowRun(env, dir, ev); |
| 216 | + if (ev.type === "deployment_review" && ev.action === "requested") return deploymentReview(env, dir, ev); | |
| 216 | 217 | if (ev.type === "pull_request" || ev.type === "issues" || ev.type === "issue_comment" || ev.type === "pull_request_review") return threadEvent(env, dir, ev); |
| 217 | 218 | } |
| 218 | 219 | |
| 220 | +/** | |
| 221 | + * A run's jobs wait on an environment's required reviewers: each reviewer who can still read the | |
| 222 | + * repository gets a notification and an email (unless they turned Actions email off). | |
| 223 | + */ | |
| 224 | +async function deploymentReview(env: Env, dir: Directory, ev: Extract<DiffineryEvent, { payload: unknown }>): Promise<void> { | |
| 225 | + const p = ev.payload as { environment?: string; workflow_run?: { id?: string; run_number?: number; name?: string; display_title?: string; head_sha?: string; head_branch?: string }; reviewers?: Array<{ reviewer?: { id?: string } }> }; | |
| 226 | + const repo = await dir.repoById(ev.repoId); | |
| 227 | + const run = p.workflow_run; | |
| 228 | + if (!repo || !run?.id || !p.environment) return; | |
| 229 | + const ids = (p.reviewers ?? []).map((r) => r.reviewer?.id).filter((x): x is string => typeof x === "string" && x !== ev.actor.id); | |
| 230 | + const accounts = (await readable(dir, repo, ids)).filter((a) => a.kind === "user"); | |
| 231 | + if (!accounts.length) return; | |
| 232 | + const url = `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${run.id}`; | |
| 233 | + const prefs = await new MailStore(env.DB).prefsFor(accounts.map((a) => a.id)); | |
| 234 | + const data = { repo: `${ev.owner}/${ev.repo}`, environment: p.environment, workflow: run.name ?? "", runNumber: run.run_number ?? 0, title: run.display_title ?? "", branch: run.head_branch ?? "", sha: run.head_sha ?? "", requestor: ev.actor.login, url }; | |
| 235 | + await Promise.all([ | |
| 236 | + dir.notify( | |
| 237 | + accounts.filter((a) => prefs.get(a.id)?.actions.web !== false).map((a) => a.id), | |
| 238 | + { repoId: ev.repoId, kind: "deployment_review", title: `Deployment to ${p.environment} in ${ev.owner}/${ev.repo} is waiting for your review`, url }, | |
| 239 | + ), | |
| 240 | + enqueueMail(env, accounts.map((a) => ({ kind: "deployment_review_requested" as const, data, to: { accountId: a.id }, reason: "review_requested" as const, at: ev.at }))), | |
| 241 | + ]); | |
| 242 | +} | |
| 243 | + | |
| 219 | 244 | // ---------------------------------------------------------------- account and security emails |
| 220 | 245 | |
| 221 | 246 | /** People who administer a repository: its owner (or the org's owners and admins) and admin collaborators. */ |
modifiedapps/diffinery/src/mail/samples.ts+1−0View file
| @@ -65,6 +65,7 @@ | ||
| 65 | 65 | item("token-expiring", "Access token expiring", { kind: "token_expiring", reason: "security_alert", data: { name: "ci-deploy", prefix: "dfy_Ab12Cd", expiresAt: exp } }), |
| 66 | 66 | item("agent-created", "Agent added", { kind: "agent_created", reason: "security_alert", data: { agent: "review-bot" } }), |
| 67 | 67 | item("model-key-added", "Model key added", { kind: "model_key_added", reason: "security_alert", data: { provider: "Anthropic", hint: "x7Qa" } }), |
| 68 | + item("deployment-review", "Deployment waiting for review", { kind: "deployment_review_requested", reason: "review_requested", data: { repo, environment: "production", workflow: "Deploy", runNumber: 128, title: "Release 2.4", branch: "main", sha: "4f1c2e9d0b7a6c5e3f2d1c0b9a8e7d6c5b4a3f2e", requestor: me, url: `${pub}/${repo}/actions/runs/sample` } }), | |
| 68 | 69 | item("review-limit", "Agent review limit reached", { kind: "review_limit_reached", reason: "your_activity", data: { repo, limit: 50, pr: 42 } }), |
| 69 | 70 | item("push-bypassed", "Push protection bypassed", { kind: "push_bypassed", reason: "security_alert", data: { repo, pusher: me, kinds: ["AWS access key"], reason: "Fake key used by a test fixture", refs: ["refs/heads/main"] } }), |
| 70 | 71 | item("push-blocked", "Push blocked", { kind: "push_blocked", reason: "security_alert", data: { repo, kinds: ["GitHub token"], refs: ["refs/heads/feature"] } }), |
modifiedapps/diffinery/src/mail/templates.ts+16−0View file
| @@ -478,6 +478,22 @@ | ||
| 478 | 478 | url: page, |
| 479 | 479 | }; |
| 480 | 480 | } |
| 481 | + case "deployment_review_requested": { | |
| 482 | + const d = job.data; | |
| 483 | + return { | |
| 484 | + subject: `[${d.repo}] Deployment to ${d.environment} is waiting for your review`, | |
| 485 | + preheader: `${d.requestor}'s ${d.workflow} run #${d.runNumber} on ${d.branch} waits for a required reviewer before it deploys to ${d.environment}.`, | |
| 486 | + eyebrow: { text: "Deployment review", tone: "amber" }, | |
| 487 | + title: ["Deploy to ", { b: d.environment }, " in ", { a: d.repo, href: `${pub}/${d.repo}` }, "?"], | |
| 488 | + blocks: [ | |
| 489 | + { p: [`You're a required reviewer for the ${d.environment} environment. Its jobs in this run wait, with no runner started and no secret handed out, until a reviewer approves or rejects them. One approval is enough.`] }, | |
| 490 | + { kv: [["Run", [{ a: `${d.workflow} #${d.runNumber}`, href: d.url }]], ["Title", d.title || d.workflow], ["Branch", [{ code: d.branch }]], ["Commit", [{ code: d.sha.slice(0, 12) }]], ["Started by", d.requestor], ["When", at(job.at)]] }, | |
| 491 | + { button: { label: "Review deployment", url: d.url } }, | |
| 492 | + ], | |
| 493 | + context: repoContext(pub, d.repo), | |
| 494 | + url: d.url, | |
| 495 | + }; | |
| 496 | + } | |
| 481 | 497 | case "push_bypassed": { |
| 482 | 498 | const d = job.data; |
| 483 | 499 | return { |
modifiedapps/diffinery/src/mail/types.ts+2−0View file
| @@ -78,6 +78,7 @@ | ||
| 78 | 78 | | { kind: "agent_created"; data: { agent: string } } |
| 79 | 79 | | { kind: "model_key_added"; data: { provider: string; hint: string } } |
| 80 | 80 | | { kind: "review_limit_reached"; data: { repo: string; limit: number; pr: number } } |
| 81 | + | { kind: "deployment_review_requested"; data: { repo: string; environment: string; workflow: string; runNumber: number; title: string; branch: string; sha: string; requestor: string; url: string } } | |
| 81 | 82 | | { kind: "push_bypassed"; data: { repo: string; pusher: string; kinds: string[]; reason: string; refs?: string[] } } |
| 82 | 83 | | { kind: "push_blocked"; data: { repo: string; kinds: string[]; refs?: string[]; paths?: string[] } } |
| 83 | 84 | | { kind: "account_status"; data: { status: "disabled" | "active" } } |
| @@ -137,6 +138,7 @@ | ||
| 137 | 138 | case "repo_transferred": |
| 138 | 139 | return "repos"; |
| 139 | 140 | case "run": |
| 141 | + case "deployment_review_requested": | |
| 140 | 142 | return "actions"; |
| 141 | 143 | case "thread": |
| 142 | 144 | return job.reason === "subscribed" ? "watching" : "participating"; |
modifiedapps/diffinery/src/repo/RepoDO.ts+12−0View file
| @@ -993,6 +993,18 @@ | ||
| 993 | 993 | startJobKey(...a: Parameters<Actions["startJobKey"]>) { |
| 994 | 994 | return this.actions.startJobKey(...a); |
| 995 | 995 | } |
| 996 | + jobGate(...a: Parameters<Actions["jobGate"]>) { | |
| 997 | + return this.actions.jobGate(...a); | |
| 998 | + } | |
| 999 | + pendingDeployments(...a: Parameters<Actions["pendingDeployments"]>) { | |
| 1000 | + return this.actions.pendingDeployments(...a); | |
| 1001 | + } | |
| 1002 | + reviewDeployments(...a: Parameters<Actions["reviewDeployments"]>) { | |
| 1003 | + return this.actions.reviewDeployments(...a); | |
| 1004 | + } | |
| 1005 | + deploymentReviews(...a: Parameters<Actions["deploymentReviews"]>) { | |
| 1006 | + return this.actions.deploymentReviews(...a); | |
| 1007 | + } | |
| 996 | 1008 | jobTimedOut(...a: Parameters<Actions["jobTimedOut"]>) { |
| 997 | 1009 | return this.actions.jobTimedOut(...a); |
| 998 | 1010 | } |
modifiedapps/diffinery/src/repo/actions.ts+245−7View file
| @@ -31,6 +31,7 @@ | ||
| 31 | 31 | import { ReusableError, expandReusable, localKey, parseCallRef, type Expanded, type Source } from "./reusable.js"; |
| 32 | 32 | import { ancestors, combineResults, jobStatus, runnerLabels, type JobNode } from "./job-graph.js"; |
| 33 | 33 | import { schedulerStub } from "../lib/schedules.js"; |
| 34 | +import { APPROVAL_DAYS, type Deployment, type DeploymentReview, type Reviewer } from "./deployments.js"; | |
| 34 | 35 | import { referencedSecrets, RUNNER_SECRETS, secretsFor } from "./job-secrets.js"; |
| 35 | 36 | |
| 36 | 37 | /** How long a sandbox token works after the sandbox starts. */ |
| @@ -100,6 +101,8 @@ | ||
| 100 | 101 | head_sha: string | null; |
| 101 | 102 | /** 1 when the code the run executes came from an agent; its jobs then act as an agent. */ |
| 102 | 103 | agent_origin?: number; |
| 104 | + /** Account ids that started earlier attempts of this run (JSON list), for prevent_self_review. */ | |
| 105 | + prior_actors?: string | null; | |
| 103 | 106 | }; |
| 104 | 107 | |
| 105 | 108 | export type JobRow = { |
| @@ -107,8 +110,11 @@ | ||
| 107 | 110 | run_id: string; |
| 108 | 111 | job_key: string; |
| 109 | 112 | name: string; |
| 110 | - /** pending: held back by its concurrency group until the group's running job finishes. */ | |
| 111 | - status: "queued" | "pending" | "waiting" | "in_progress" | "completed"; | |
| 113 | + /** | |
| 114 | + * pending: held back by its concurrency group until the group's running job finishes. | |
| 115 | + * approval: waiting on its environment's required reviewers or wait timer; nothing has started. | |
| 116 | + */ | |
| 117 | + status: "queued" | "pending" | "approval" | "waiting" | "in_progress" | "completed"; | |
| 112 | 118 | conclusion: Conclusion | "timed_out" | null; |
| 113 | 119 | matrix: string; |
| 114 | 120 | strategy: string; |
| @@ -131,9 +137,28 @@ | ||
| 131 | 137 | log_chars: number; |
| 132 | 138 | fleet_held: number; |
| 133 | 139 | runner_id: number | null; |
| 140 | + /** `environment:` as evaluated at launch (null for jobs without one, and for jobs launched before it was stored). */ | |
| 141 | + environment: string | null; | |
| 142 | + /** When the job started waiting on its environment's protection rules, and "released" once they passed. */ | |
| 143 | + gate_since: string | null; | |
| 144 | + gate_state: string | null; | |
| 134 | 145 | placement_tries: number; |
| 135 | 146 | }; |
| 136 | 147 | |
| 148 | +/** An environment a run's jobs wait on (GitHub's pending deployment). */ | |
| 149 | +export type PendingDeployment = { | |
| 150 | + environment: { id: number; name: string }; | |
| 151 | + waitTimer: number; | |
| 152 | + waitTimerStartedAt: string; | |
| 153 | + reviewers: Reviewer[]; | |
| 154 | + preventSelfReview: boolean; | |
| 155 | + /** Still waiting for a required reviewer (false once approved, or when only a timer applies). */ | |
| 156 | + needsReview: boolean; | |
| 157 | + currentUserCanApprove: boolean; | |
| 158 | + /** The viewer is listed but started the run, and the environment prevents self-review. */ | |
| 159 | + selfBlocked: boolean; | |
| 160 | +}; | |
| 161 | + | |
| 137 | 162 | export type RunInfo = { |
| 138 | 163 | id: string; |
| 139 | 164 | number: number; |
| @@ -896,7 +921,7 @@ | ||
| 896 | 921 | const ctx = { ...baseCtx, matrix, strategy: { "fail-fast": failFast, "job-index": i, "job-total": combos.length, "max-parallel": maxParallel } }; |
| 897 | 922 | let name = short; |
| 898 | 923 | if (!job.name && Object.keys(matrix).length) name = `${short} (${Object.values(matrix).map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(", ")})`; |
| 899 | - let leg: { labels: string[]; timeout: number; continueOnError: boolean; group: string | null; cancel: boolean }; | |
| 924 | + let leg: { labels: string[]; timeout: number; continueOnError: boolean; group: string | null; cancel: boolean; environment: string | null }; | |
| 900 | 925 | try { |
| 901 | 926 | if (job.name) name = interpolate(job.name, ctx); |
| 902 | 927 | const continueOnError = evaluateValue(job.continueOnError, ctx); |
| @@ -907,6 +932,8 @@ | ||
| 907 | 932 | continueOnError: continueOnError === true || continueOnError === "true", |
| 908 | 933 | group: (job.concurrency ? interpolate(job.concurrency.group, ctx).trim() : "") || null, |
| 909 | 934 | cancel: cancel === true || cancel === "true", |
| 935 | + // Evaluated once, here: its protection rules gate the launch, and runnerSpec deploys to this same name. | |
| 936 | + environment: job.environment ? interpolate(job.environment.name, ctx).trim() : null, | |
| 910 | 937 | }; |
| 911 | 938 | } catch (e) { |
| 912 | 939 | fail(label(name), `Error evaluating the job for this matrix combination: ${(e as Error).message}`); |
| @@ -920,7 +947,7 @@ | ||
| 920 | 947 | } |
| 921 | 948 | const id = newId(); |
| 922 | 949 | this.sql.exec( |
| 923 | - "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, labels, continue_on_error, concurrency_group, concurrency_cancel) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?)", | |
| 950 | + "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, labels, continue_on_error, concurrency_group, concurrency_cancel, environment) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 924 | 951 | id, |
| 925 | 952 | runId, |
| 926 | 953 | jobKey, |
| @@ -932,6 +959,7 @@ | ||
| 932 | 959 | leg.continueOnError ? 1 : 0, |
| 933 | 960 | leg.group, |
| 934 | 961 | leg.cancel ? 1 : 0, |
| 962 | + leg.environment, | |
| 935 | 963 | ); |
| 936 | 964 | ids.push(id); |
| 937 | 965 | }); |
| @@ -946,7 +974,7 @@ | ||
| 946 | 974 | |
| 947 | 975 | private async launch(jobId: string): Promise<void> { |
| 948 | 976 | const job = this.jobRow(jobId); |
| 949 | - if (job.status === "completed" || !(await this.concurrencySlot(job))) return; | |
| 977 | + if (job.status === "completed" || !(await this.gate(job)) || !(await this.concurrencySlot(job))) return; | |
| 950 | 978 | const run = this.runRow(job.run_id); |
| 951 | 979 | const m = this.repo.meta; |
| 952 | 980 | if (job.labels) { |
| @@ -977,7 +1005,207 @@ | ||
| 977 | 1005 | } |
| 978 | 1006 | } |
| 979 | 1007 | |
| 1008 | + | |
| 1009 | + // ---------------------------------------------------------------- environment protection rules | |
| 1010 | + | |
| 1011 | + private jobUrl(run: RunRow, jobId: string): string { | |
| 1012 | + const m = this.repo.meta; | |
| 1013 | + return `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`; | |
| 1014 | + } | |
| 1015 | + | |
| 980 | 1016 | /** |
| 1017 | + * Whose run this is, for prevent_self_review, as account ids (logins can be renamed): who started | |
| 1018 | + * it and its earlier attempts, and for each of them that is an agent, the account that owns it. | |
| 1019 | + */ | |
| 1020 | + async selfIds(run: RunRow): Promise<Set<string>> { | |
| 1021 | + const actor = JSON.parse(run.actor) as Actor; | |
| 1022 | + const ids = new Set([actor.id, ...((run.prior_actors ? JSON.parse(run.prior_actors) : []) as string[])].filter(Boolean)); | |
| 1023 | + const dir = new Directory(this.env); | |
| 1024 | + for (const id of [...ids]) { | |
| 1025 | + const a = await dir.account(id).catch(() => null); | |
| 1026 | + if (a?.kind === "agent" && a.owner_id) ids.add(a.owner_id); | |
| 1027 | + } | |
| 1028 | + return ids; | |
| 1029 | + } | |
| 1030 | + | |
| 1031 | + /** | |
| 1032 | + * A job whose environment has required reviewers or a wait timer waits here, before a token is | |
| 1033 | + * minted or a runner started, in the status `approval`: no concurrency slot, no hosted slot. | |
| 1034 | + * Returns whether the job may go on to start. The branch policy is checked first, so a ref that | |
| 1035 | + * may never deploy fails at once instead of asking anyone. | |
| 1036 | + */ | |
| 1037 | + private async gate(job: JobRow): Promise<boolean> { | |
| 1038 | + if (!job.environment || job.gate_state === "released") return true; | |
| 1039 | + const rules = this.repo.deploys.protection(job.environment); | |
| 1040 | + if (!rules) { | |
| 1041 | + if (job.status === "approval") this.release(job); | |
| 1042 | + return true; | |
| 1043 | + } | |
| 1044 | + const run = this.runRow(job.run_id); | |
| 1045 | + const fail = async (message: string) => { | |
| 1046 | + await this.completeJob(job.id, { conclusion: "failure", outputs: {}, failure: { message } }); | |
| 1047 | + return false; | |
| 1048 | + }; | |
| 1049 | + if (!this.repo.deploys.refAllowed(job.environment, run.ref)) return fail(`${run.ref.replace(/^refs\/(heads|tags)\//, "")} is not allowed to deploy to ${job.environment} due to environment protection rules.`); | |
| 1050 | + const decision = rules.reviewers.length ? this.repo.deploys.decision(run.id, job.environment) : null; | |
| 1051 | + if (decision === "rejected") { | |
| 1052 | + const r = this.repo.deploys.reviews(run.id).find((x) => x.environment.toLowerCase() === job.environment!.toLowerCase() && x.state === "rejected")!; | |
| 1053 | + return fail(`The deployment to ${job.environment} was rejected by ${r.user.login}${r.comment ? `: ${r.comment}` : "."}`); | |
| 1054 | + } | |
| 1055 | + let since = job.gate_since; | |
| 1056 | + if (job.status !== "approval") { | |
| 1057 | + since = now(); | |
| 1058 | + this.sql.exec("UPDATE jobs SET status = 'approval', gate_since = ? WHERE id = ?", since, job.id); | |
| 1059 | + this.sql.exec("UPDATE runs SET status = 'waiting', updated_at = ? WHERE id = ? AND status != 'completed'", since, run.id); | |
| 1060 | + const actor = JSON.parse(run.actor) as Actor; | |
| 1061 | + this.repo.deploys.jobWaiting({ id: job.id, ref: run.ref, sha: run.sha, logUrl: this.jobUrl(run, job.id), actor: run.agent_origin ? { ...actor, kind: "agent" } : actor }, job.environment); | |
| 1062 | + this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId: job.id, detailsUrl: this.jobUrl(run, job.id) }); | |
| 1063 | + // Reviewers hear about it once per run and environment, from its first job to wait. | |
| 1064 | + const first = !this.sql.exec("SELECT 1 FROM jobs WHERE run_id = ? AND environment = ? COLLATE NOCASE AND gate_since IS NOT NULL AND id != ?", run.id, job.environment, job.id).toArray().length; | |
| 1065 | + if (first && rules.reviewers.length && !decision) this.emitReview("requested", run, job.environment, rules.reviewers, actor, null, ""); | |
| 1066 | + this.repo.broadcast(`run:${run.id}`, { action: "job", jobId: job.id }); | |
| 1067 | + this.repo.broadcast("runs", { action: "waiting", runId: run.id }); | |
| 1068 | + } | |
| 1069 | + const timerEnds = Date.parse(since!) + rules.waitTimer * 60_000; | |
| 1070 | + if ((rules.reviewers.length && decision !== "approved") || Date.now() < timerEnds) return false; | |
| 1071 | + this.release({ ...job, status: "approval" }); | |
| 1072 | + return true; | |
| 1073 | + } | |
| 1074 | + | |
| 1075 | + /** The job's protection rules passed: it goes on to start, and its run stops waiting once no other job does. */ | |
| 1076 | + private release(job: JobRow): void { | |
| 1077 | + this.sql.exec("UPDATE jobs SET gate_state = 'released', status = 'queued' WHERE id = ?", job.id); | |
| 1078 | + this.syncWaiting(job.run_id); | |
| 1079 | + } | |
| 1080 | + | |
| 1081 | + private syncWaiting(runId: string): void { | |
| 1082 | + if (this.sql.exec("SELECT 1 FROM jobs WHERE run_id = ? AND status = 'approval'", runId).toArray().length) return; | |
| 1083 | + this.sql.exec("UPDATE runs SET status = 'in_progress', updated_at = ? WHERE id = ? AND status = 'waiting'", now(), runId); | |
| 1084 | + } | |
| 1085 | + | |
| 1086 | + private async wakeGate(runId: string, jobId: string): Promise<void> { | |
| 1087 | + try { | |
| 1088 | + await (await this.env.RUN_WORKFLOW.get(runId)).sendEvent({ type: `gate-${jobId}`, payload: {} }); | |
| 1089 | + } catch (e) { | |
| 1090 | + console.error("failed to wake a job waiting for approval", e); | |
| 1091 | + } | |
| 1092 | + } | |
| 1093 | + | |
| 1094 | + private emitReview(action: "requested" | "approved" | "rejected", run: RunRow, environment: string, reviewers: Reviewer[], by: Actor, state: string | null, comment: string): void { | |
| 1095 | + const m = this.repo.meta; | |
| 1096 | + const url = `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}`; | |
| 1097 | + const ids = Object.fromEntries(this.repo.deploys.environments().map((e) => [e.name.toLowerCase(), e.id])); | |
| 1098 | + this.repo.emit({ | |
| 1099 | + type: "deployment_review", | |
| 1100 | + action, | |
| 1101 | + repoId: m.repoId, | |
| 1102 | + owner: m.owner, | |
| 1103 | + repo: m.name, | |
| 1104 | + payload: { | |
| 1105 | + since: now(), | |
| 1106 | + environment: environment, | |
| 1107 | + environment_id: ids[environment.toLowerCase()] ?? null, | |
| 1108 | + comment, | |
| 1109 | + ...(state ? { state } : {}), | |
| 1110 | + workflow_run: { id: run.id, run_number: run.run_number, name: run.workflow_name, display_title: run.display_title, head_sha: run.head_sha ?? run.sha, head_branch: run.ref.replace(/^refs\/(heads|tags)\//, ""), html_url: url }, | |
| 1111 | + reviewers: reviewers.map((r) => ({ type: "User", reviewer: { login: r.login, id: r.id } })), | |
| 1112 | + ...(action === "requested" ? { requestor: { login: by.login } } : { approver: { login: by.login } }), | |
| 1113 | + }, | |
| 1114 | + actor: by, | |
| 1115 | + at: now(), | |
| 1116 | + }); | |
| 1117 | + } | |
| 1118 | + | |
| 1119 | + /** | |
| 1120 | + * Where a job waiting on its environment stands, for the run's Workflow: what it waits for and | |
| 1121 | + * until when. Re-checks the rules first, so a job whose time came starts here. | |
| 1122 | + */ | |
| 1123 | + async jobGate(jobId: string): Promise<{ status: JobRow["status"]; wait: "review" | "timer" | null; until: string | null }> { | |
| 1124 | + let job = this.jobRow(jobId); | |
| 1125 | + if (job.status === "approval") await this.launch(jobId); | |
| 1126 | + job = this.jobRow(jobId); | |
| 1127 | + if (job.status !== "approval") return { status: job.status, wait: null, until: null }; | |
| 1128 | + const rules = this.repo.deploys.protection(job.environment!) ?? { reviewers: [], waitTimer: 0, preventSelfReview: false }; | |
| 1129 | + const since = Date.parse(job.gate_since!); | |
| 1130 | + const deadline = since + APPROVAL_DAYS * 86400_000; | |
| 1131 | + if (Date.now() >= deadline) { | |
| 1132 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The deployment to ${job.environment} wasn't approved within ${APPROVAL_DAYS} days.` } }); | |
| 1133 | + return { status: "completed", wait: null, until: null }; | |
| 1134 | + } | |
| 1135 | + const decided = this.repo.deploys.decision(job.run_id, job.environment!) === "approved"; | |
| 1136 | + if (rules.reviewers.length && !decided) return { status: "approval", wait: "review", until: new Date(deadline).toISOString() }; | |
| 1137 | + return { status: "approval", wait: "timer", until: new Date(since + rules.waitTimer * 60_000).toISOString() }; | |
| 1138 | + } | |
| 1139 | + | |
| 1140 | + /** The environments a run's jobs wait on, with who may approve them (GitHub's pending deployments). */ | |
| 1141 | + async pendingDeployments(runId: string, viewer: { id: string; login: string; mayReview?: boolean } | null): Promise<PendingDeployment[]> { | |
| 1142 | + const run = this.runRow(runId); | |
| 1143 | + const waiting = this.sql.exec<{ environment: string; since: string }>("SELECT environment, MIN(gate_since) AS since FROM jobs WHERE run_id = ? AND status = 'approval' GROUP BY environment COLLATE NOCASE", run.id).toArray(); | |
| 1144 | + const self = viewer ? await this.selfIds(run) : new Set<string>(); | |
| 1145 | + return waiting.flatMap((w) => { | |
| 1146 | + const env = this.repo.deploys.environments().find((e) => e.name.toLowerCase() === w.environment.toLowerCase()); | |
| 1147 | + if (!env) return []; | |
| 1148 | + const p = env.protection; | |
| 1149 | + const decided = this.repo.deploys.decision(run.id, env.name); | |
| 1150 | + // A listed reviewer who lost access to the repository no longer decides (the caller says so in mayReview). | |
| 1151 | + const listed = !!viewer && viewer.mayReview !== false && p.reviewers.some((r) => r.id === viewer.id); | |
| 1152 | + const selfBlocked = !!viewer && p.preventSelfReview && self.has(viewer.id); | |
| 1153 | + return [ | |
| 1154 | + { | |
| 1155 | + environment: { id: env.id, name: env.name }, | |
| 1156 | + waitTimer: p.waitTimer, | |
| 1157 | + waitTimerStartedAt: w.since, | |
| 1158 | + reviewers: p.reviewers, | |
| 1159 | + preventSelfReview: p.preventSelfReview, | |
| 1160 | + needsReview: p.reviewers.length > 0 && decided !== "approved", | |
| 1161 | + currentUserCanApprove: listed && !selfBlocked && p.reviewers.length > 0 && !decided, | |
| 1162 | + selfBlocked, | |
| 1163 | + }, | |
| 1164 | + ]; | |
| 1165 | + }); | |
| 1166 | + } | |
| 1167 | + | |
| 1168 | + /** | |
| 1169 | + * A required reviewer approves or rejects environments a run waits on. The caller checked that | |
| 1170 | + * `reviewer` is a person signed in or using a write token (never an agent or a job); here the | |
| 1171 | + * reviewer must be listed on every environment, and not whoever started the run when the | |
| 1172 | + * environment prevents self-review. One approval is enough. Approved jobs start once their | |
| 1173 | + * wait timer has also passed; rejected ones fail. | |
| 1174 | + */ | |
| 1175 | + async reviewDeployments(runId: string, input: { environmentIds: number[]; state: "approved" | "rejected"; comment: string }, reviewer: { id: string; login: string; mayReview: boolean }): Promise<{ reviews: DeploymentReview[]; deployments: Deployment[] }> { | |
| 1176 | + const run = this.runRow(runId); | |
| 1177 | + if (!reviewer.mayReview) throw new HttpError(403, "only people with access to this repository review its deployments"); | |
| 1178 | + if (!input.environmentIds.length) throw new HttpError(422, "environment_ids must name at least one environment"); | |
| 1179 | + const pending = await this.pendingDeployments(run.id, reviewer); | |
| 1180 | + const chosen = input.environmentIds.map((id) => { | |
| 1181 | + const p = pending.find((x) => x.environment.id === id); | |
| 1182 | + if (!p || !p.needsReview) throw new HttpError(422, `environment ${id} isn't waiting for a review in this run`); | |
| 1183 | + if (p.selfBlocked) throw new HttpError(403, `${p.environment.name} doesn't let whoever started a run approve it`); | |
| 1184 | + if (!p.currentUserCanApprove) throw new HttpError(403, `you aren't a required reviewer for ${p.environment.name}`); | |
| 1185 | + return p; | |
| 1186 | + }); | |
| 1187 | + const by: Actor = { id: reviewer.id, login: reviewer.login, kind: "user" }; | |
| 1188 | + const out: DeploymentReview[] = []; | |
| 1189 | + const jobIds: string[] = []; | |
| 1190 | + for (const p of chosen) { | |
| 1191 | + out.push(this.repo.deploys.addReview(run.id, p.environment.name, input.state, input.comment, reviewer)); | |
| 1192 | + this.emitReview(input.state, run, p.environment.name, p.reviewers, by, input.state, input.comment); | |
| 1193 | + const jobs = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND status = 'approval' AND environment = ? COLLATE NOCASE", run.id, p.environment.name).toArray(); | |
| 1194 | + for (const j of jobs) { | |
| 1195 | + jobIds.push(j.id); | |
| 1196 | + await this.launch(j.id); | |
| 1197 | + await this.wakeGate(run.id, j.id); | |
| 1198 | + } | |
| 1199 | + } | |
| 1200 | + this.repo.broadcast(`run:${run.id}`, { action: "reviewed" }); | |
| 1201 | + return { reviews: out, deployments: this.repo.deploys.jobDeployments(jobIds) }; | |
| 1202 | + } | |
| 1203 | + | |
| 1204 | + deploymentReviews(runId: string): DeploymentReview[] { | |
| 1205 | + return this.repo.deploys.reviews(this.runRow(runId).id); | |
| 1206 | + } | |
| 1207 | + | |
| 1208 | + /** | |
| 981 | 1209 | * The job's container got no machine (its start failed, or it stopped before the runner reported |
| 982 | 1210 | * in). Put the job back in line and have the run's workflow start it again after a pause, or fail it |
| 983 | 1211 | * once it has waited MAX_PLACEMENT_TRIES times. |
| @@ -1274,7 +1502,9 @@ | ||
| 1274 | 1502 | // Re-running code an agent wrote keeps it acting as the agent, whoever re-runs it. |
| 1275 | 1503 | agentOrigin: !!old.agent_origin, |
| 1276 | 1504 | }); |
| 1277 | - this.sql.exec("UPDATE runs SET attempt = ?, run_number = ? WHERE id = ?", old.attempt + 1, old.run_number, run!.id); | |
| 1505 | + // Whoever started an earlier attempt still can't approve this one where an environment prevents self-review. | |
| 1506 | + const prior = [...new Set([...((old.prior_actors ? JSON.parse(old.prior_actors) : []) as string[]), (JSON.parse(old.actor) as Actor).id])]; | |
| 1507 | + this.sql.exec("UPDATE runs SET attempt = ?, run_number = ?, prior_actors = ? WHERE id = ?", old.attempt + 1, old.run_number, JSON.stringify(prior), run!.id); | |
| 1278 | 1508 | return toRun(this.runRow(run!.id)); |
| 1279 | 1509 | } |
| 1280 | 1510 | |
| @@ -1375,7 +1605,9 @@ | ||
| 1375 | 1605 | if (def.environment) { |
| 1376 | 1606 | try { |
| 1377 | 1607 | const ctx = { github: this.githubContext(run, short, ""), inputs: scope.inputs, vars: this.variables(), matrix: JSON.parse(job.matrix), strategy: JSON.parse(job.strategy), needs: await this.needsResults(run, exp, exp.needs.get(job.job_key) ?? [], parent) }; |
| 1378 | - const name = interpolate(def.environment.name, ctx); | |
| 1608 | + // The name evaluated at launch, whose protection rules the job passed (older jobs evaluate it here). | |
| 1609 | + const name = job.environment ?? interpolate(def.environment.name, ctx); | |
| 1610 | + if (this.repo.deploys.protection(name) && job.gate_state !== "released") throw new Error(`The environment ${name} requires approval this job didn't get. Re-run the job to ask for it.`); | |
| 1379 | 1611 | const url = def.environment.url && !def.environment.url.includes("steps.") ? interpolate(def.environment.url, ctx) : undefined; |
| 1380 | 1612 | const logUrl = `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`; |
| 1381 | 1613 | // Code an agent wrote deploys as that agent (environment rules hold it to the agent's limits). |
| @@ -1486,6 +1718,7 @@ | ||
| 1486 | 1718 | private async completeJob(jobId: string, completion: JobCompletion, override?: "timed_out"): Promise<void> { |
| 1487 | 1719 | const job = this.jobRow(jobId); |
| 1488 | 1720 | if (job.status === "completed") return; |
| 1721 | + const gated = job.status === "approval"; | |
| 1489 | 1722 | const run = this.runRow(job.run_id); |
| 1490 | 1723 | const t = now(); |
| 1491 | 1724 | const conclusion = override ?? completion.conclusion; |
| @@ -1552,6 +1785,11 @@ | ||
| 1552 | 1785 | } catch (e) { |
| 1553 | 1786 | console.error("failed to notify workflow", e); |
| 1554 | 1787 | } |
| 1788 | + // The Workflow waits for a job held for approval on its own event. | |
| 1789 | + if (gated) { | |
| 1790 | + this.syncWaiting(run.id); | |
| 1791 | + await this.wakeGate(run.id, jobId); | |
| 1792 | + } | |
| 1555 | 1793 | } |
| 1556 | 1794 | |
| 1557 | 1795 | // ---------------------------------------------------------------- secrets and variables |
modifiedapps/diffinery/src/repo/deployments.ts+134−27View file
| @@ -38,8 +38,10 @@ | ||
| 38 | 38 | created_at: string; |
| 39 | 39 | }; |
| 40 | 40 | |
| 41 | -type EnvironmentRow = { id: number; name: string; branch_policy: string | null; created_at: string; updated_at: string }; | |
| 41 | +type EnvironmentRow = { id: number; name: string; branch_policy: string | null; reviewers: string | null; wait_timer: number; prevent_self_review: number; created_at: string; updated_at: string }; | |
| 42 | 42 | |
| 43 | +type ReviewRow = { id: number; run_id: string; environment: string; state: "approved" | "rejected"; comment: string; user_id: string; user_login: string; created_at: string }; | |
| 44 | + | |
| 43 | 45 | export type Person = { id: string; login: string; kind: string }; |
| 44 | 46 | |
| 45 | 47 | export type Deployment = { |
| @@ -88,8 +90,24 @@ | ||
| 88 | 90 | return true; |
| 89 | 91 | } |
| 90 | 92 | |
| 91 | -export type Environment = { id: number; name: string; policy: BranchPolicy | null; createdAt: string; updatedAt: string }; | |
| 93 | +/** A required reviewer: a person with access to the repository (Diffinery has no teams). */ | |
| 94 | +export type Reviewer = { type: "User"; id: string; login: string }; | |
| 92 | 95 | |
| 96 | +/** An environment's protection rules beyond its branch policy, as GitHub has them. */ | |
| 97 | +export type Protection = { reviewers: Reviewer[]; waitTimer: number; preventSelfReview: boolean }; | |
| 98 | + | |
| 99 | +export type Environment = { id: number; name: string; policy: BranchPolicy | null; protection: Protection; createdAt: string; updatedAt: string }; | |
| 100 | + | |
| 101 | +/** One required reviewer's decision on an environment for a workflow run. */ | |
| 102 | +export type DeploymentReview = { id: number; runId: string; environment: string; state: "approved" | "rejected"; comment: string; user: { id: string; login: string }; createdAt: string }; | |
| 103 | + | |
| 104 | +/** GitHub's limits: up to 6 required reviewers, and a wait timer of up to 30 days. */ | |
| 105 | +export const MAX_REVIEWERS = 6; | |
| 106 | +export const MAX_WAIT_TIMER = 43_200; | |
| 107 | +/** A job waits this long for its environment's reviewers before it fails, as on GitHub. */ | |
| 108 | +export const APPROVAL_DAYS = 30; | |
| 109 | +export const MAX_REVIEW_COMMENT = 4_000; | |
| 110 | + | |
| 93 | 111 | const STATES = new Set(["error", "failure", "inactive", "in_progress", "queued", "pending", "success"]); |
| 94 | 112 | const ENV_NAME = /^[^\x00-\x1f\x7f/\\]{1,255}$/; |
| 95 | 113 | const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; |
| @@ -331,7 +349,14 @@ | ||
| 331 | 349 | return this.sql.exec<EnvironmentRow>("SELECT * FROM environments WHERE name = ?", name).toArray()[0]; |
| 332 | 350 | } |
| 333 | 351 | |
| 334 | - private toEnvironment = (r: EnvironmentRow): Environment => ({ id: r.id, name: r.name, policy: r.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, createdAt: r.created_at, updatedAt: r.updated_at }); | |
| 352 | + private toEnvironment = (r: EnvironmentRow): Environment => ({ | |
| 353 | + id: r.id, | |
| 354 | + name: r.name, | |
| 355 | + policy: r.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, | |
| 356 | + protection: { reviewers: r.reviewers ? (JSON.parse(r.reviewers) as Reviewer[]) : [], waitTimer: r.wait_timer ?? 0, preventSelfReview: !!r.prevent_self_review }, | |
| 357 | + createdAt: r.created_at, | |
| 358 | + updatedAt: r.updated_at, | |
| 359 | + }); | |
| 335 | 360 | |
| 336 | 361 | private ensureEnvironment(name: string): EnvironmentRow { |
| 337 | 362 | const existing = this.envRow(name); |
| @@ -352,14 +377,31 @@ | ||
| 352 | 377 | } |
| 353 | 378 | |
| 354 | 379 | /** |
| 355 | - * Create or update an environment. Required reviewers and wait timers are not | |
| 356 | - * enforced yet, so they are refused rather than silently ignored. | |
| 380 | + * Create or update an environment. `reviewers` arrive resolved to people (http/environments.ts | |
| 381 | + * checks each is a person with access); a field left out keeps its value. | |
| 357 | 382 | */ |
| 358 | - upsertEnvironment(name: string, input: { wait_timer?: number; reviewers?: unknown[] | null; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null }, caller: Caller): Environment { | |
| 383 | + upsertEnvironment( | |
| 384 | + name: string, | |
| 385 | + input: { wait_timer?: unknown; reviewers?: Reviewer[] | null; prevent_self_review?: unknown; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null }, | |
| 386 | + caller: Caller, | |
| 387 | + ): Environment { | |
| 359 | 388 | this.requireHumanAdmin(caller); |
| 360 | 389 | if (!ENV_NAME.test(name)) throw new HttpError(422, "invalid environment name"); |
| 361 | - if ((input.wait_timer ?? 0) > 0 || (input.reviewers?.length ?? 0) > 0) throw new HttpError(422, "required reviewers and wait timers are not supported yet"); | |
| 390 | + if (input.wait_timer !== undefined && input.wait_timer !== null && (typeof input.wait_timer !== "number" || !Number.isInteger(input.wait_timer) || input.wait_timer < 0 || input.wait_timer > MAX_WAIT_TIMER)) { | |
| 391 | + throw new HttpError(422, `wait_timer must be a whole number of minutes from 0 to ${MAX_WAIT_TIMER}`); | |
| 392 | + } | |
| 393 | + if (input.prevent_self_review !== undefined && input.prevent_self_review !== null && typeof input.prevent_self_review !== "boolean") throw new HttpError(422, "prevent_self_review must be true or false"); | |
| 394 | + if (input.reviewers) { | |
| 395 | + if (input.reviewers.length > MAX_REVIEWERS) throw new HttpError(422, `an environment can have at most ${MAX_REVIEWERS} required reviewers`); | |
| 396 | + if (input.reviewers.some((r) => r.type !== "User" || typeof r.id !== "string" || typeof r.login !== "string")) throw new HttpError(422, "reviewers must be people"); | |
| 397 | + } | |
| 362 | 398 | const row = this.ensureEnvironment(name); |
| 399 | + if (input.wait_timer !== undefined && input.wait_timer !== null) this.sql.exec("UPDATE environments SET wait_timer = ?, updated_at = ? WHERE id = ?", input.wait_timer, now(), row.id); | |
| 400 | + if (input.prevent_self_review !== undefined && input.prevent_self_review !== null) this.sql.exec("UPDATE environments SET prevent_self_review = ?, updated_at = ? WHERE id = ?", input.prevent_self_review ? 1 : 0, now(), row.id); | |
| 401 | + if (input.reviewers !== undefined) { | |
| 402 | + const unique = [...new Map((input.reviewers ?? []).map((r) => [r.id, { type: "User" as const, id: r.id, login: r.login }])).values()]; | |
| 403 | + this.sql.exec("UPDATE environments SET reviewers = ?, updated_at = ? WHERE id = ?", unique.length ? JSON.stringify(unique) : null, now(), row.id); | |
| 404 | + } | |
| 363 | 405 | if (input.deployment_branch_policy !== undefined) { |
| 364 | 406 | const p = input.deployment_branch_policy; |
| 365 | 407 | if (p && !!p.protected_branches === !!p.custom_branch_policies) throw new HttpError(422, "set exactly one of protected_branches and custom_branch_policies"); |
| @@ -370,6 +412,38 @@ | ||
| 370 | 412 | return this.environment(name); |
| 371 | 413 | } |
| 372 | 414 | |
| 415 | + /** The environment's reviewers and wait timer, or null when it has neither (its jobs then start at once). */ | |
| 416 | + protection(name: string): Protection | null { | |
| 417 | + const r = this.envRow(name); | |
| 418 | + if (!r) return null; | |
| 419 | + const p = this.toEnvironment(r).protection; | |
| 420 | + return p.reviewers.length || p.waitTimer > 0 ? p : null; | |
| 421 | + } | |
| 422 | + | |
| 423 | + /** Record a reviewer's decision for a run's environment (the caller checked who may decide). */ | |
| 424 | + addReview(runId: string, environment: string, state: "approved" | "rejected", comment: string, user: { id: string; login: string }): DeploymentReview { | |
| 425 | + const t = now(); | |
| 426 | + this.sql.exec("INSERT INTO deployment_reviews (run_id, environment, state, comment, user_id, user_login, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", runId, environment, state, comment.slice(0, MAX_REVIEW_COMMENT), user.id, user.login, t); | |
| 427 | + return this.toReview(this.sql.exec<ReviewRow>("SELECT * FROM deployment_reviews WHERE id = last_insert_rowid()").one()); | |
| 428 | + } | |
| 429 | + | |
| 430 | + /** The decision on a run's environment so far: the first one counts, as on GitHub. */ | |
| 431 | + decision(runId: string, environment: string): "approved" | "rejected" | null { | |
| 432 | + return this.sql.exec<{ state: "approved" | "rejected" }>("SELECT state FROM deployment_reviews WHERE run_id = ? AND environment = ? COLLATE NOCASE ORDER BY id LIMIT 1", runId, environment).toArray()[0]?.state ?? null; | |
| 433 | + } | |
| 434 | + | |
| 435 | + /** The deployments workflow jobs recorded, newest first. */ | |
| 436 | + jobDeployments(jobIds: string[]): Deployment[] { | |
| 437 | + if (!jobIds.length) return []; | |
| 438 | + return this.sql.exec<DeploymentRow>(`SELECT * FROM deployments WHERE job_id IN (${jobIds.map(() => "?").join(",")}) ORDER BY id DESC`, ...jobIds).toArray().map((r) => this.toDeployment(r)); | |
| 439 | + } | |
| 440 | + | |
| 441 | + reviews(runId: string): DeploymentReview[] { | |
| 442 | + return this.sql.exec<ReviewRow>("SELECT * FROM deployment_reviews WHERE run_id = ? ORDER BY id", runId).toArray().map((r) => this.toReview(r)); | |
| 443 | + } | |
| 444 | + | |
| 445 | + private toReview = (r: ReviewRow): DeploymentReview => ({ id: r.id, runId: r.run_id, environment: r.environment, state: r.state, comment: r.comment, user: { id: r.user_id, login: r.user_login }, createdAt: r.created_at }); | |
| 446 | + | |
| 373 | 447 | deleteEnvironment(name: string, caller: Caller): void { |
| 374 | 448 | this.requireHumanAdmin(caller); |
| 375 | 449 | this.environment(name); |
| @@ -466,6 +540,53 @@ | ||
| 466 | 540 | // ---------------------------------------------------------------- workflow jobs with `environment:` |
| 467 | 541 | |
| 468 | 542 | /** |
| 543 | + * Called when a job starts waiting on its environment's protection rules: records its | |
| 544 | + * deployment with a `waiting` status, as GitHub shows a deployment waiting for review. | |
| 545 | + */ | |
| 546 | + jobWaiting(job: { id: string; ref: string; sha: string; logUrl: string; actor: Person }, name: string): void { | |
| 547 | + if (this.sql.exec("SELECT 1 FROM deployments WHERE job_id = ?", job.id).toArray().length) return; | |
| 548 | + const env = this.ensureEnvironment(name).name; | |
| 549 | + const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" }; | |
| 550 | + const id = this.insertJobDeployment(job, env); | |
| 551 | + const t = now(); | |
| 552 | + // Not a state the REST API accepts from clients; GitHub reports it the same way for deployments waiting for review. | |
| 553 | + this.sql.exec( | |
| 554 | + "INSERT INTO deployment_statuses (deployment_id, state, description, environment, log_url, creator_id, creator_login, creator_kind, created_at) VALUES (?, 'waiting', ?, ?, ?, ?, ?, ?, ?)", | |
| 555 | + id, | |
| 556 | + "Waiting for the environment's protection rules", | |
| 557 | + env, | |
| 558 | + job.logUrl, | |
| 559 | + system.id, | |
| 560 | + system.login, | |
| 561 | + system.kind, | |
| 562 | + t, | |
| 563 | + ); | |
| 564 | + } | |
| 565 | + | |
| 566 | + private insertJobDeployment(job: { id: string; ref: string; sha: string; actor: Person }, name: string): number { | |
| 567 | + const t = now(); | |
| 568 | + const ref = job.ref.replace(/^refs\/(heads|tags)\//, ""); | |
| 569 | + this.sql.exec( | |
| 570 | + "INSERT INTO deployments (sha, ref, task, environment, original_environment, payload, production, creator_id, creator_login, creator_kind, job_id, created_at, updated_at) VALUES (?, ?, 'deploy', ?, ?, '{}', ?, ?, ?, ?, ?, ?, ?)", | |
| 571 | + job.sha, | |
| 572 | + ref, | |
| 573 | + name, | |
| 574 | + name, | |
| 575 | + name.toLowerCase() === "production" ? 1 : 0, | |
| 576 | + job.actor.id, | |
| 577 | + job.actor.login, | |
| 578 | + job.actor.kind, | |
| 579 | + job.id, | |
| 580 | + t, | |
| 581 | + t, | |
| 582 | + ); | |
| 583 | + const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; | |
| 584 | + const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" }; | |
| 585 | + this.emit("deployment", "created", { deployment: { id, sha: job.sha, ref, task: "deploy", environment: name, description: null, payload: {}, created_at: t } }, system); | |
| 586 | + return id; | |
| 587 | + } | |
| 588 | + | |
| 589 | + /** | |
| 469 | 590 | * Called when a job with `environment:` starts: checks the environment's |
| 470 | 591 | * branch policy, records a deployment with an in_progress status, and |
| 471 | 592 | * returns the environment's secrets and variables for the job. |
| @@ -476,26 +597,12 @@ | ||
| 476 | 597 | const name = this.ensureEnvironment(raw).name; |
| 477 | 598 | if (!this.refAllowed(name, job.ref)) throw new HttpError(403, `${job.ref.replace(/^refs\/(heads|tags)\//, "")} is not allowed to deploy to ${name} due to environment protection rules.`); |
| 478 | 599 | const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" }; |
| 479 | - if (!this.sql.exec("SELECT 1 FROM deployments WHERE job_id = ?", job.id).toArray().length) { | |
| 480 | - const t = now(); | |
| 481 | - this.sql.exec( | |
| 482 | - "INSERT INTO deployments (sha, ref, task, environment, original_environment, payload, production, creator_id, creator_login, creator_kind, job_id, created_at, updated_at) VALUES (?, ?, 'deploy', ?, ?, '{}', ?, ?, ?, ?, ?, ?, ?)", | |
| 483 | - job.sha, | |
| 484 | - job.ref.replace(/^refs\/(heads|tags)\//, ""), | |
| 485 | - name, | |
| 486 | - name, | |
| 487 | - name.toLowerCase() === "production" ? 1 : 0, | |
| 488 | - job.actor.id, | |
| 489 | - job.actor.login, | |
| 490 | - job.actor.kind, | |
| 491 | - job.id, | |
| 492 | - t, | |
| 493 | - t, | |
| 494 | - ); | |
| 495 | - const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; | |
| 496 | - const ref = job.ref.replace(/^refs\/(heads|tags)\//, ""); | |
| 497 | - this.emit("deployment", "created", { deployment: { id, sha: job.sha, ref, task: "deploy", environment: name, description: null, payload: {}, created_at: t } }, system); | |
| 498 | - this.createStatus(id, { state: "in_progress", log_url: job.logUrl, environment_url: environment.url && /^https?:\/\//i.test(environment.url) ? environment.url : undefined }, system); | |
| 600 | + const existing = this.sql.exec<{ id: number }>("SELECT id FROM deployments WHERE job_id = ?", job.id).toArray()[0]; | |
| 601 | + const environmentUrl = environment.url && /^https?:\/\//i.test(environment.url) ? environment.url : undefined; | |
| 602 | + if (!existing) this.createStatus(this.insertJobDeployment(job, name), { state: "in_progress", log_url: job.logUrl, environment_url: environmentUrl }, system); | |
| 603 | + else if (this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", existing.id).toArray()[0]?.state === "waiting") { | |
| 604 | + // Its protection rules passed: the deployment it recorded while waiting goes on. | |
| 605 | + this.createStatus(existing.id, { state: "in_progress", log_url: job.logUrl, environment_url: environmentUrl }, system); | |
| 499 | 606 | } |
| 500 | 607 | const secrets: Record<string, string> = {}; |
| 501 | 608 | const rows = this.secretsAllowed(name, job.ref, job.actor.kind) ? this.sql.exec<{ name: string; ciphertext: string }>("SELECT name, ciphertext FROM environment_secrets WHERE environment = ?", name).toArray() : []; |
modifiedapps/diffinery/src/repo/schema.ts+18−0View file
| @@ -121,6 +121,12 @@ | ||
| 121 | 121 | CREATE TABLE IF NOT EXISTS environments ( |
| 122 | 122 | id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, branch_policy TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL |
| 123 | 123 | ); |
| 124 | +-- Required reviewers' decisions on a run's environments (repo/deployments.ts): one approval per run and environment is enough. | |
| 125 | +CREATE TABLE IF NOT EXISTS deployment_reviews ( | |
| 126 | + id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, environment TEXT NOT NULL COLLATE NOCASE, state TEXT NOT NULL, comment TEXT NOT NULL DEFAULT '', | |
| 127 | + user_id TEXT NOT NULL, user_login TEXT NOT NULL, created_at TEXT NOT NULL | |
| 128 | +); | |
| 129 | +CREATE INDEX IF NOT EXISTS deployment_reviews_run ON deployment_reviews(run_id); | |
| 124 | 130 | CREATE TABLE IF NOT EXISTS environment_secrets (environment TEXT NOT NULL COLLATE NOCASE, name TEXT NOT NULL, ciphertext TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (environment, name)); |
| 125 | 131 | CREATE TABLE IF NOT EXISTS environment_variables (environment TEXT NOT NULL COLLATE NOCASE, name TEXT NOT NULL, value TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (environment, name)); |
| 126 | 132 | CREATE TABLE IF NOT EXISTS reactions ( |
| @@ -202,6 +208,18 @@ | ||
| 202 | 208 | // credential also needs the person who started the task (task_person) to still be allowed. |
| 203 | 209 | ["sandboxes", "kind", "TEXT NOT NULL DEFAULT 'api'"], |
| 204 | 210 | ["sandboxes", "task_person", "TEXT"], |
| 211 | + // Environment protection rules (repo/deployments.ts): required reviewers as JSON [{type: "User", id, login}], | |
| 212 | + // a wait timer in minutes, and whether whoever started a run may approve it. | |
| 213 | + ["environments", "reviewers", "TEXT"], | |
| 214 | + ["environments", "wait_timer", "INTEGER NOT NULL DEFAULT 0"], | |
| 215 | + ["environments", "prevent_self_review", "INTEGER NOT NULL DEFAULT 0"], | |
| 216 | + // The job's `environment:` name, evaluated when it is launched; runnerSpec uses this, never a second evaluation. | |
| 217 | + ["jobs", "environment", "TEXT"], | |
| 218 | + // While a job waits on its environment's protection rules: when it started waiting, and "released" once they passed. | |
| 219 | + ["jobs", "gate_since", "TEXT"], | |
| 220 | + ["jobs", "gate_state", "TEXT"], | |
| 221 | + // Logins that started earlier attempts of a re-run (JSON list): prevent_self_review covers them too. | |
| 222 | + ["runs", "prior_actors", "TEXT"], | |
| 205 | 223 | ]; |
| 206 | 224 | |
| 207 | 225 | export function migrateRepoSchema(sql: SqlStorage): void { |
modifiedapps/diffinery/src/runner/workflow.ts+21−3View file
| @@ -13,7 +13,8 @@ | ||
| 13 | 13 | * job's containers as soon as the jobs it needs have finished, and waits for the |
| 14 | 14 | * runner to report completion. Retries, sleeps and waits survive restarts of the |
| 15 | 15 | * platform. Each job costs a fixed number of steps (start, then a status check |
| 16 | - * and one wait per matrix leg), however long it runs. | |
| 16 | + * and one wait per matrix leg), however long it runs, plus a few per wake while | |
| 17 | + * its environment's reviewers or wait timer hold it. | |
| 17 | 18 | */ |
| 18 | 19 | export class RunWorkflow extends WorkflowEntrypoint<Env, RunParams> { |
| 19 | 20 | override async run(event: WorkflowEvent<RunParams>, step: WorkflowStep): Promise<unknown> { |
| @@ -32,12 +33,29 @@ | ||
| 32 | 33 | } |
| 33 | 34 | |
| 34 | 35 | const waitFor = async (jobId: string) => { |
| 36 | + let status = await step.do(`job ${jobId} info`, () => repo().jobStatus(jobId)); | |
| 37 | + // Held by its environment's protection rules: wait for a reviewer's decision or the wait timer, | |
| 38 | + // for up to 30 days (repo/actions.ts jobGate fails it after that). Each wake re-checks the rules. | |
| 39 | + for (let i = 0; status === "approval" && i < 100; i++) { | |
| 40 | + const gate = await step.do(`gate ${jobId} ${i}`, () => repo().jobGate(jobId)); | |
| 41 | + status = gate.status; | |
| 42 | + if (status !== "approval") break; | |
| 43 | + const until = Date.parse(gate.until ?? "") || Date.now() + 60_000; | |
| 44 | + if (gate.wait === "timer") await step.sleepUntil(`timer ${jobId} ${i}`, until); | |
| 45 | + else { | |
| 46 | + try { | |
| 47 | + await step.waitForEvent(`approval ${jobId} ${i}`, { type: `gate-${jobId}`, timeout: Math.max(1000, until - Date.now()) }); | |
| 48 | + } catch { | |
| 49 | + // The deadline passed: the next gate check fails the job. | |
| 50 | + } | |
| 51 | + } | |
| 52 | + } | |
| 53 | + if (status === "completed" || status === "approval") return; | |
| 35 | 54 | // A job whose container got no machine comes back with `retryInSeconds`: pause, start it again |
| 36 | 55 | // and keep waiting. The first round keeps its step names, so runs already in flight replay. |
| 37 | 56 | for (let i = 0; ; i++) { |
| 38 | 57 | const n = i ? ` ${i}` : ""; |
| 39 | - const status = await step.do(`job ${jobId} info${n}`, () => repo().jobStatus(jobId)); | |
| 40 | - if (status === "completed") return; | |
| 58 | + if (i && (await step.do(`job ${jobId} info${n}`, () => repo().jobStatus(jobId))) === "completed") return; | |
| 41 | 59 | let event: { payload?: { retryInSeconds?: number } }; |
| 42 | 60 | try { |
| 43 | 61 | event = await step.waitForEvent(`wait ${jobId}${n}`, { type: `job-${jobId}`, timeout: "6 hours" }); |
modifiedapps/diffinery/src/web/actions.tsx+103−2View file
| @@ -4,7 +4,9 @@ | ||
| 4 | 4 | import type { FC } from "hono/jsx"; |
| 5 | 5 | import { loadRepo } from "../http/common.js"; |
| 6 | 6 | import { HttpError } from "../lib/util.js"; |
| 7 | -import type { JobInfo, RunInfo } from "../repo/actions.js"; | |
| 7 | +import type { JobInfo, PendingDeployment, RunInfo } from "../repo/actions.js"; | |
| 8 | +import type { DeploymentReview } from "../repo/deployments.js"; | |
| 9 | +import { reviewDeployments, viewerFor } from "../http/environment-reviews.js"; | |
| 8 | 10 | import { atLeast } from "../repo/types.js"; |
| 9 | 11 | import { ICONS } from "./assets.js"; |
| 10 | 12 | import { KCheckbox, KSelect } from "./kumo.js"; |
| @@ -99,6 +101,77 @@ | ||
| 99 | 101 | return { lines: out.slice(0, MAX_LOG_LINES), complete, truncated: out.length >= MAX_LOG_LINES }; |
| 100 | 102 | } |
| 101 | 103 | |
| 104 | +/** Environments the run waits on: who may approve, any wait timer, and a review form for listed reviewers. */ | |
| 105 | +const ReviewDeployments: FC<{ base: string; runId: string; pending: PendingDeployment[]; reviews: DeploymentReview[]; t: string; canAct: boolean }> = ({ base, runId, pending, reviews, t, canAct }) => { | |
| 106 | + const mine = pending.filter((p) => p.currentUserCanApprove); | |
| 107 | + return ( | |
| 108 | + <div class="box" style="border-color:var(--amber)"> | |
| 109 | + <div class="head"> | |
| 110 | + <h3>Waiting for {pending.some((p) => p.needsReview) ? "review" : "a wait timer"}</h3> | |
| 111 | + </div> | |
| 112 | + <ul class="list"> | |
| 113 | + {pending.map((p) => { | |
| 114 | + const timerEnds = new Date(Date.parse(p.waitTimerStartedAt) + p.waitTimer * 60_000).toISOString(); | |
| 115 | + return ( | |
| 116 | + <li> | |
| 117 | + <div class="grow"> | |
| 118 | + <b>{p.environment.name}</b> | |
| 119 | + <div class="meta"> | |
| 120 | + {p.needsReview ? <>Needs an approval from {p.reviewers.map((r) => r.login).join(", ")}. </> : p.reviewers.length ? "Approved. " : null} | |
| 121 | + {p.waitTimer > 0 ? ( | |
| 122 | + <> | |
| 123 | + Wait timer of {p.waitTimer} minute{p.waitTimer === 1 ? "" : "s"}, ends <Time at={timerEnds} />.{" "} | |
| 124 | + </> | |
| 125 | + ) : null} | |
| 126 | + {p.selfBlocked ? "You started this run, and this environment doesn't let you approve it." : null} | |
| 127 | + </div> | |
| 128 | + </div> | |
| 129 | + </li> | |
| 130 | + ); | |
| 131 | + })} | |
| 132 | + </ul> | |
| 133 | + {canAct && mine.length ? ( | |
| 134 | + <Form action={`${base}/actions/runs/${runId}/review`} t={t} class="body" style="border-top:1px solid var(--border2)"> | |
| 135 | + <div class="field"> | |
| 136 | + <label>Environments to review</label> | |
| 137 | + {mine.map((p) => ( | |
| 138 | + <div> | |
| 139 | + <KCheckbox name="environment" value={String(p.environment.id)} checked label={p.environment.name} /> | |
| 140 | + </div> | |
| 141 | + ))} | |
| 142 | + </div> | |
| 143 | + <div class="field"> | |
| 144 | + <label>Comment</label> | |
| 145 | + <textarea name="comment" maxlength={4000} style="min-height:60px" placeholder="Optional"></textarea> | |
| 146 | + </div> | |
| 147 | + <div class="row"> | |
| 148 | + <button class="btn primary" name="state" value="approved"> | |
| 149 | + Approve and deploy | |
| 150 | + </button> | |
| 151 | + <button class="btn danger" name="state" value="rejected"> | |
| 152 | + Reject | |
| 153 | + </button> | |
| 154 | + </div> | |
| 155 | + </Form> | |
| 156 | + ) : null} | |
| 157 | + {reviews.length ? <ReviewHistory reviews={reviews} /> : null} | |
| 158 | + </div> | |
| 159 | + ); | |
| 160 | +}; | |
| 161 | + | |
| 162 | +const ReviewHistory: FC<{ reviews: DeploymentReview[] }> = ({ reviews }) => ( | |
| 163 | + <ul class="list"> | |
| 164 | + {reviews.map((r) => ( | |
| 165 | + <li> | |
| 166 | + <div class="grow small"> | |
| 167 | + <b>{r.user.login}</b> {r.state} <b>{r.environment}</b> <Time at={r.createdAt} /> | |
| 168 | + {r.comment ? <div class="muted">{r.comment}</div> : null} | |
| 169 | + </div> | |
| 170 | + </li> | |
| 171 | + ))} | |
| 172 | + </ul> | |
| 173 | +); | |
| 174 | + | |
| 102 | 175 | export function registerActions(web: WebApp): void { |
| 103 | 176 | web.get("/:owner/:repo/actions", async (c) => { |
| 104 | 177 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| @@ -277,6 +350,9 @@ | ||
| 277 | 350 | const runId = prm(c, "runId"); |
| 278 | 351 | const run = (await stub.run(runId)) as unknown as RunWithJobs; |
| 279 | 352 | const artifacts = await stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>); |
| 353 | + const principal = c.get("principal"); | |
| 354 | + const viewer = await viewerFor(c, repo); | |
| 355 | + const [pending, reviews] = await Promise.all([run.status !== "completed" ? stub.pendingDeployments(run.id, viewer) : Promise.resolve([] as PendingDeployment[]), stub.deploymentReviews(run.id)]); | |
| 280 | 356 | const groups = new Map<string, JobInfo[]>(); |
| 281 | 357 | for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]); |
| 282 | 358 | const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived; |
| @@ -346,6 +422,16 @@ | ||
| 346 | 422 | </dl> |
| 347 | 423 | </div> |
| 348 | 424 | </div> |
| 425 | + {pending.length ? ( | |
| 426 | + <ReviewDeployments base={base} runId={run.id} pending={pending} reviews={reviews} t={t} canAct={principal.type === "account" && principal.account.kind === "user" && !repo.archived} /> | |
| 427 | + ) : reviews.length ? ( | |
| 428 | + <div class="box"> | |
| 429 | + <div class="head"> | |
| 430 | + <h3>Deployment reviews</h3> | |
| 431 | + </div> | |
| 432 | + <ReviewHistory reviews={reviews} /> | |
| 433 | + </div> | |
| 434 | + ) : null} | |
| 349 | 435 | {[...groups.entries()].map(([key, jobs]) => ( |
| 350 | 436 | <div class="box jobgroup"> |
| 351 | 437 | <div class="head"> |
| @@ -360,7 +446,7 @@ | ||
| 360 | 446 | {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null} |
| 361 | 447 | {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null} |
| 362 | 448 | </div> |
| 363 | - <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status}</span> | |
| 449 | + <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status === "approval" ? "waiting for approval" : j.status}</span> | |
| 364 | 450 | </a> |
| 365 | 451 | ))} |
| 366 | 452 | </div> |
| @@ -391,6 +477,21 @@ | ||
| 391 | 477 | ); |
| 392 | 478 | }); |
| 393 | 479 | |
| 480 | + post(web, "/:owner/:repo/actions/runs/:runId/review", async (c, f) => { | |
| 481 | + // Listed reviewers may have read access only; the rules are checked again where the decision is made. | |
| 482 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 483 | + const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`; | |
| 484 | + const state = f.s("state"); | |
| 485 | + return act( | |
| 486 | + c, | |
| 487 | + back, | |
| 488 | + async () => { | |
| 489 | + await reviewDeployments(c, l, prm(c, "runId"), { environment_ids: f.all("environment").map(Number), state, comment: f.s("comment") }); | |
| 490 | + }, | |
| 491 | + state === "approved" ? "Approved. The jobs start once any wait timer has passed." : "Rejected. The waiting jobs fail.", | |
| 492 | + ); | |
| 493 | + }); | |
| 494 | + | |
| 394 | 495 | post(web, "/:owner/:repo/actions/runs/:runId/cancel", async (c) => { |
| 395 | 496 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); |
| 396 | 497 | const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`; |
modifiedapps/diffinery/src/web/repo-settings.tsx+158−1View file
| @@ -4,6 +4,8 @@ | ||
| 4 | 4 | import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; |
| 5 | 5 | import { loadRepo } from "../http/common.js"; |
| 6 | 6 | import { HttpError } from "../lib/util.js"; |
| 7 | +import { MAX_REVIEWERS, MAX_WAIT_TIMER, type Environment } from "../repo/deployments.js"; | |
| 8 | +import { resolveReviewers } from "../http/environment-reviews.js"; | |
| 7 | 9 | import type { ProtectionRules, Role } from "../repo/types.js"; |
| 8 | 10 | import { ICONS } from "./assets.js"; |
| 9 | 11 | import { KCheckbox, KRadio, KSelect, type KOption } from "./kumo.js"; |
| @@ -11,7 +13,7 @@ | ||
| 11 | 13 | import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; |
| 12 | 14 | |
| 13 | 15 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 14 | -export type SettingsTab = "general" | "access" | "branches" | "actions" | "secrets" | "webhooks" | "agent-review" | "project"; | |
| 16 | +export type SettingsTab = "general" | "access" | "branches" | "actions" | "environments" | "secrets" | "webhooks" | "agent-review" | "project"; | |
| 15 | 17 | type Tab = SettingsTab; |
| 16 | 18 | |
| 17 | 19 | const WEBHOOK_EVENTS = ["push", "pull_request", "pull_request_review", "issues", "issue_comment", "workflow_run", "workflow_dispatch"]; |
| @@ -36,6 +38,7 @@ | ||
| 36 | 38 | ["access", "Collaborators", `${base}/access`], |
| 37 | 39 | ["branches", "Branch protection", `${base}/branches`], |
| 38 | 40 | ["actions", "Actions", `${base}/actions`], |
| 41 | + ["environments", "Environments", `${base}/environments`], | |
| 39 | 42 | ["secrets", "Secrets and variables", `${base}/secrets`], |
| 40 | 43 | ["webhooks", "Webhooks", `${base}/webhooks`], |
| 41 | 44 | ["agent-review", "Agent review", `${base}/agent-review`], |
| @@ -520,6 +523,112 @@ | ||
| 520 | 523 | ); |
| 521 | 524 | } |
| 522 | 525 | |
| 526 | +// ---------------------------------------------------------------- environments | |
| 527 | + | |
| 528 | +const envPath = (l: Loaded, name?: string) => `${repoUrl(l.repo)}/settings/environments${name ? `/${encodeURIComponent(name)}` : ""}`; | |
| 529 | + | |
| 530 | +function rulesSummary(e: Environment): string { | |
| 531 | + const parts: string[] = []; | |
| 532 | + if (e.protection.reviewers.length) parts.push(`${e.protection.reviewers.length} required reviewer${e.protection.reviewers.length === 1 ? "" : "s"}`); | |
| 533 | + if (e.protection.waitTimer) parts.push(`${e.protection.waitTimer} minute wait timer`); | |
| 534 | + if (e.policy) parts.push(e.policy.protected_branches ? "protected branches only" : `${e.policy.patterns.length} branch and tag rule${e.policy.patterns.length === 1 ? "" : "s"}`); | |
| 535 | + return parts.join(" · ") || "No protection rules"; | |
| 536 | +} | |
| 537 | + | |
| 538 | +async function environmentsPage(c: AppContext, l: Loaded) { | |
| 539 | + const t = csrfToken(c); | |
| 540 | + const envs = await l.stub.environments(); | |
| 541 | + return page( | |
| 542 | + c, | |
| 543 | + l, | |
| 544 | + "environments", | |
| 545 | + <> | |
| 546 | + <h1>Environments</h1> | |
| 547 | + <p class="muted"> | |
| 548 | + A workflow job with <code>environment: name</code> deploys to that environment and gets its secrets. Required reviewers and a wait timer hold such jobs before anything starts: nothing runs, and no secret is handed out, until a reviewer approves and the timer has passed. | |
| 549 | + </p> | |
| 550 | + <div class="box"> | |
| 551 | + {envs.length ? ( | |
| 552 | + <ul class="list"> | |
| 553 | + {envs.map((e) => ( | |
| 554 | + <li> | |
| 555 | + <div class="grow"> | |
| 556 | + <a class="title" href={envPath(l, e.name)}> | |
| 557 | + {e.name} | |
| 558 | + </a> | |
| 559 | + <div class="meta">{rulesSummary(e)}</div> | |
| 560 | + </div> | |
| 561 | + </li> | |
| 562 | + ))} | |
| 563 | + </ul> | |
| 564 | + ) : ( | |
| 565 | + <div class="empty">No environments yet. A workflow job that names one creates it.</div> | |
| 566 | + )} | |
| 567 | + <Form action={envPath(l)} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)"> | |
| 568 | + <div class="field m0" style="min-width:240px"> | |
| 569 | + <label>New environment</label> | |
| 570 | + <input name="name" type="text" required maxlength={255} placeholder="production" autocomplete="off" /> | |
| 571 | + </div> | |
| 572 | + <button class="btn">Add environment</button> | |
| 573 | + </Form> | |
| 574 | + </div> | |
| 575 | + </>, | |
| 576 | + ); | |
| 577 | +} | |
| 578 | + | |
| 579 | +async function environmentPage(c: AppContext, l: Loaded, name: string) { | |
| 580 | + const t = csrfToken(c); | |
| 581 | + const e = await l.stub.environment(name); | |
| 582 | + const p = e.protection; | |
| 583 | + return page( | |
| 584 | + c, | |
| 585 | + l, | |
| 586 | + "environments", | |
| 587 | + <> | |
| 588 | + <div class="muted small"> | |
| 589 | + <a href={envPath(l)}>Environments</a> / | |
| 590 | + </div> | |
| 591 | + <h1>{e.name}</h1> | |
| 592 | + <Form action={envPath(l, e.name)} t={t} class="box"> | |
| 593 | + <div class="head"> | |
| 594 | + <h2>Deployment protection rules</h2> | |
| 595 | + </div> | |
| 596 | + <div class="body"> | |
| 597 | + <div class="field"> | |
| 598 | + <label>Required reviewers</label> | |
| 599 | + <input name="reviewers" type="text" value={p.reviewers.map((r) => r.login).join(", ")} placeholder="alice, bob" autocomplete="off" /> | |
| 600 | + <div class="hint"> | |
| 601 | + Up to {MAX_REVIEWERS} people with access to this repository, separated by commas. One approval lets a run's jobs deploy here. Agents can't be reviewers, and an agent's approval never counts. | |
| 602 | + </div> | |
| 603 | + </div> | |
| 604 | + <div class="field"> | |
| 605 | + <KCheckbox name="prevent_self_review" checked={p.preventSelfReview} label="Prevent self-review: whoever started a run (or re-ran it, or owns the agent that did) can't approve it" /> | |
| 606 | + </div> | |
| 607 | + <div class="field" style="max-width:260px"> | |
| 608 | + <label>Wait timer (minutes)</label> | |
| 609 | + <input name="wait_timer" type="number" min={0} max={MAX_WAIT_TIMER} value={String(p.waitTimer)} /> | |
| 610 | + <div class="hint">Jobs wait this long after they're ready to start, up to {MAX_WAIT_TIMER} (30 days). Nobody can skip it.</div> | |
| 611 | + </div> | |
| 612 | + <div class="field"> | |
| 613 | + <label>Deployment branches</label> | |
| 614 | + <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> | |
| 615 | + </div> | |
| 616 | + <button class="btn primary">Save protection rules</button> | |
| 617 | + </div> | |
| 618 | + </Form> | |
| 619 | + <Form action={`${envPath(l, e.name)}/delete`} t={t} class="box" confirm={`Delete the ${e.name} environment, its secrets and its variables?`}> | |
| 620 | + <div class="body row between"> | |
| 621 | + <div> | |
| 622 | + <b>Delete this environment</b> | |
| 623 | + <div class="muted small">Its secrets, variables and protection rules go with it. Past deployments stay.</div> | |
| 624 | + </div> | |
| 625 | + <button class="btn danger">Delete environment</button> | |
| 626 | + </div> | |
| 627 | + </Form> | |
| 628 | + </>, | |
| 629 | + ); | |
| 630 | +} | |
| 631 | + | |
| 523 | 632 | async function secretsPage(c: AppContext, l: Loaded) { |
| 524 | 633 | const base = repoUrl(l.repo); |
| 525 | 634 | const t = csrfToken(c); |
| @@ -700,6 +809,54 @@ | ||
| 700 | 809 | web.get("/:owner/:repo/settings/branches", async (c) => branchesPage(c, await load(c))); |
| 701 | 810 | web.get("/:owner/:repo/settings/actions", async (c) => actionsPage(c, await load(c))); |
| 702 | 811 | web.get("/:owner/:repo/settings/secrets", async (c) => secretsPage(c, await load(c))); |
| 812 | + web.get("/:owner/:repo/settings/environments", async (c) => environmentsPage(c, await load(c))); | |
| 813 | + web.get("/:owner/:repo/settings/environments/:env", async (c) => environmentPage(c, await load(c), prm(c, "env"))); | |
| 814 | + | |
| 815 | + post(web, "/:owner/:repo/settings/environments", async (c, f) => { | |
| 816 | + const l = await load(c); | |
| 817 | + const name = f.s("name").trim(); | |
| 818 | + return act(c, envPath(l), async () => { | |
| 819 | + const e = await l.stub.upsertEnvironment(name, {}, l.caller); | |
| 820 | + audit(c, l, "environment.update", { environment: e.name }); | |
| 821 | + return envPath(l, e.name); | |
| 822 | + }); | |
| 823 | + }); | |
| 824 | + | |
| 825 | + post(web, "/:owner/:repo/settings/environments/:env", async (c, f) => { | |
| 826 | + const l = await load(c); | |
| 827 | + const name = prm(c, "env"); | |
| 828 | + return act( | |
| 829 | + c, | |
| 830 | + envPath(l, name), | |
| 831 | + async () => { | |
| 832 | + const logins = f | |
| 833 | + .s("reviewers") | |
| 834 | + .split(/[\s,]+/) | |
| 835 | + .map((x) => x.replace(/^@/, "")) | |
| 836 | + .filter(Boolean); | |
| 837 | + const reviewers = await resolveReviewers(c, l.repo, logins.map((login) => ({ type: "User", login }))); | |
| 838 | + const raw = f.s("wait_timer").trim(); | |
| 839 | + const waitTimer = raw === "" ? 0 : Number(raw); | |
| 840 | + const e = await l.stub.upsertEnvironment(name, { reviewers, wait_timer: waitTimer, prevent_self_review: f.s("prevent_self_review") === "1" }, l.caller); | |
| 841 | + 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 }); | |
| 842 | + }, | |
| 843 | + "Protection rules saved.", | |
| 844 | + ); | |
| 845 | + }); | |
| 846 | + | |
| 847 | + post(web, "/:owner/:repo/settings/environments/:env/delete", async (c) => { | |
| 848 | + const l = await load(c); | |
| 849 | + const name = prm(c, "env"); | |
| 850 | + return act( | |
| 851 | + c, | |
| 852 | + envPath(l), | |
| 853 | + async () => { | |
| 854 | + await l.stub.deleteEnvironment(name, l.caller); | |
| 855 | + audit(c, l, "environment.delete", { environment: name }); | |
| 856 | + }, | |
| 857 | + `Deleted ${name}.`, | |
| 858 | + ); | |
| 859 | + }); | |
| 703 | 860 | web.get("/:owner/:repo/settings/webhooks", async (c) => webhooksPage(c, await load(c))); |
| 704 | 861 | |
| 705 | 862 | post(web, "/:owner/:repo/settings/actions", async (c, f) => { |
modifiedapps/diffinery/src/web/ui.tsx+1−1View file
| @@ -81,7 +81,7 @@ | ||
| 81 | 81 | export const Status: FC<{ status?: string | null; conclusion?: string | null }> = ({ status, conclusion }) => { |
| 82 | 82 | if (status && status !== "completed") { |
| 83 | 83 | if (status === "in_progress") return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>; |
| 84 | - return <span class="st warn" title={status === "waiting" ? "Waiting" : status === "pending" ? "Pending" : "Queued"}>{raw(ICONS.queued)}</span>; | |
| 84 | + return <span class="st warn" title={status === "approval" ? "Waiting for approval" : status === "waiting" ? "Waiting" : status === "pending" ? "Pending" : "Queued"}>{raw(ICONS.queued)}</span>; | |
| 85 | 85 | } |
| 86 | 86 | switch (conclusion) { |
| 87 | 87 | case "success": |
modifiedapps/diffinery/test/e2e/security.test.mjs+147−0View file
| @@ -2104,6 +2104,153 @@ | ||
| 2104 | 2104 | for (const login of ["ghost", "github-actions", "noreply", "support", "abuse"]) await status(422, "POST", "/user/orgs", { login }); |
| 2105 | 2105 | }); |
| 2106 | 2106 | |
| 2107 | +test("security: environment reviewers hold a deploy job until a listed person approves, and nobody else can", async () => { | |
| 2108 | + const R = `/repos/${OWNER}/gated-deploy`; | |
| 2109 | + await repoWithReadme("gated-deploy"); | |
| 2110 | + await collaborator(R, "rita", "read", T.rita); | |
| 2111 | + await collaborator(R, "sam", "read", T.sam); | |
| 2112 | + await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" }); | |
| 2113 | + const ritaId = (await ok("GET", "/user", undefined, T.rita)).id; | |
| 2114 | + | |
| 2115 | + // Only people with access can be reviewers, never an agent or a team; limits are GitHub's; agents can't set them. | |
| 2116 | + await ok("PUT", `${R}/environments/production`, {}); | |
| 2117 | + await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "olive-bot" }] }); | |
| 2118 | + await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "Team", id: 1 }] }); | |
| 2119 | + await status(422, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "milo" }] }); | |
| 2120 | + await status(422, "PUT", `${R}/environments/production`, { wait_timer: 43_201 }); | |
| 2121 | + await status(403, "PUT", `${R}/environments/production`, { reviewers: [{ type: "User", login: "rita" }] }, T.bot); | |
| 2122 | + await status(201, "PUT", `${R}/environments/production/secrets/DEPLOY_KEY`, { value: "deploy-value" }); | |
| 2123 | + const env = await ok("PUT", `${R}/environments/production`, { reviewers: [{ type: "User", id: ritaId }, { type: "User", login: "sam" }, { type: "User", login: "olive" }], prevent_self_review: true, deployment_branch_policy: null }); | |
| 2124 | + assert.equal(env.can_admins_bypass, false); | |
| 2125 | + const rule = env.protection_rules.find((r) => r.type === "required_reviewers"); | |
| 2126 | + assert.deepEqual(rule.reviewers.map((r) => r.reviewer.login).sort(), ["olive", "rita", "sam"]); | |
| 2127 | + assert.equal(rule.prevent_self_review, true); | |
| 2128 | + | |
| 2129 | + const wf = (n) => `name: deploy\non: push\njobs:\n ship${n}:\n runs-on: [self-hosted, linux]\n environment: production\n steps:\n - run: echo ship\n env:\n K: \${{ secrets.DEPLOY_KEY }}\n`; | |
| 2130 | + const claim = async () => { | |
| 2131 | + const r = await api("POST", `${R}/actions/runners/claim`, { name: "gate-runner", labels: ["linux"] }); | |
| 2132 | + if (r.status !== 200) return null; | |
| 2133 | + const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json(); | |
| 2134 | + await fetch(`${base}/runner/jobs/${r.json.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${r.json.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) }); | |
| 2135 | + return spec; | |
| 2136 | + }; | |
| 2137 | + const waitingRun = async (sha) => { | |
| 2138 | + const run = await until("a run waiting for review", async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs.find((r) => r.head_sha === sha && r.status === "waiting")); | |
| 2139 | + const jobs = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs; | |
| 2140 | + assert.equal(jobs.length, 1); | |
| 2141 | + assert.equal(jobs[0].status, "waiting"); | |
| 2142 | + // Held before anything starts: no runner can claim it, so no token or secret has left. | |
| 2143 | + assert.equal(await claim(), null); | |
| 2144 | + return run; | |
| 2145 | + }; | |
| 2146 | + | |
| 2147 | + // olive pushes: the job waits, and the deployment says so. | |
| 2148 | + const first = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy", content: b64(wf(1)) })).commit.sha; | |
| 2149 | + const run = await waitingRun(first); | |
| 2150 | + const pendingAs = async (as, id = run.id) => (await ok("GET", `${R}/actions/runs/${id}/pending_deployments`, undefined, as))[0]; | |
| 2151 | + const p = await pendingAs(T.rita); | |
| 2152 | + assert.equal(p.environment.name, "production"); | |
| 2153 | + assert.equal(p.current_user_can_approve, true); | |
| 2154 | + assert.equal((await pendingAs(T.olive)).current_user_can_approve, false, "olive started the run and the environment prevents self-review"); | |
| 2155 | + const deployment = (await ok("GET", `${R}/deployments?environment=production`))[0]; | |
| 2156 | + assert.equal((await ok("GET", `${R}/deployments/${deployment.id}/statuses`))[0].state, "waiting"); | |
| 2157 | + | |
| 2158 | + // Nobody but a listed person, other than the run's starter, with a token that may write, decides. | |
| 2159 | + const body = { environment_ids: [p.environment.id], state: "approved", comment: "ship it" }; | |
| 2160 | + assert.match((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body)).json.message, /doesn't let whoever started a run approve it/); | |
| 2161 | + assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.bot)).status, 403); | |
| 2162 | + assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.reader)).status, 403); | |
| 2163 | + assert.equal((await api("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.milo)).status, 404); | |
| 2164 | + await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, environment_ids: [999] }, T.rita); | |
| 2165 | + const approved = await ok("POST", `${R}/actions/runs/${run.id}/pending_deployments`, body, T.rita); | |
| 2166 | + assert.equal(approved[0].environment, "production"); | |
| 2167 | + // One decision counts: a second one finds nothing waiting. | |
| 2168 | + await status(422, "POST", `${R}/actions/runs/${run.id}/pending_deployments`, { ...body, state: "rejected" }, T.sam); | |
| 2169 | + | |
| 2170 | + // Now the job starts, and only now gets the environment's secret. | |
| 2171 | + const spec = await until("the approved job", claim); | |
| 2172 | + assert.equal(spec.secrets.DEPLOY_KEY, "deploy-value"); | |
| 2173 | + const done = await until("the run done", async () => { | |
| 2174 | + const r = await ok("GET", `${R}/actions/runs/${run.id}`); | |
| 2175 | + return r.status === "completed" ? r : null; | |
| 2176 | + }); | |
| 2177 | + assert.equal(done.conclusion, "success"); | |
| 2178 | + const approvals = await ok("GET", `${R}/actions/runs/${run.id}/approvals`); | |
| 2179 | + assert.deepEqual(approvals.map((a) => [a.state, a.user.login, a.comment, a.environments[0].name]), [["approved", "rita", "ship it", "production"]]); | |
| 2180 | + assert.deepEqual((await ok("GET", `${R}/deployments/${deployment.id}/statuses`)).map((s) => s.state), ["success", "in_progress", "waiting"]); | |
| 2181 | + | |
| 2182 | + // A rejection fails the job without starting it. sam decides in the browser, from the run page. | |
| 2183 | + const file = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 2184 | + const second = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 2", content: b64(wf(2)), sha: file.sha })).commit.sha; | |
| 2185 | + const run2 = await waitingRun(second); | |
| 2186 | + const page = await (await fetch(`${base}/${OWNER}/gated-deploy/actions/runs/${run2.id}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } })).text(); | |
| 2187 | + assert.match(page, /Waiting for review/); | |
| 2188 | + assert.match(page, /You started this run, and this environment doesn't let you approve it|You started this run, and this environment doesn't let you approve it/); | |
| 2189 | + const envId = (await pendingAs(T.rita, run2.id)).environment.id; | |
| 2190 | + const rejected = await submitsAs(S.sam, `/${OWNER}/gated-deploy/actions/runs/${run2.id}`, `/${OWNER}/gated-deploy/actions/runs/${run2.id}/review`, { environment: String(envId), comment: "not today", state: "rejected" }); | |
| 2191 | + assert.equal(rejected.status, 303); | |
| 2192 | + assert.match(rejected.flash, /Rejected/); | |
| 2193 | + const failed = await until("the rejected run done", async () => { | |
| 2194 | + const r = await ok("GET", `${R}/actions/runs/${run2.id}`); | |
| 2195 | + return r.status === "completed" ? r : null; | |
| 2196 | + }); | |
| 2197 | + assert.equal(failed.conclusion, "failure"); | |
| 2198 | + const job = (await ok("GET", `${R}/actions/runs/${run2.id}/jobs`)).jobs[0]; | |
| 2199 | + assert.match(job.failure.message, /rejected by sam: not today/); | |
| 2200 | + assert.equal(await claim(), null); | |
| 2201 | + | |
| 2202 | + // Self-review compares accounts, not names: sam starts a run, renames, and still can't approve it. | |
| 2203 | + await collaborator(R, "sam", "write", T.sam); | |
| 2204 | + const file3 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 2205 | + const fourth = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 4", content: b64(wf(4)), sha: file3.sha }, T.sam)).commit.sha; | |
| 2206 | + const run4 = await waitingRun(fourth); | |
| 2207 | + const env4 = (await pendingAs(T.rita, run4.id)).environment.id; | |
| 2208 | + const renamed = await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam-renamed", name: "sam" }); | |
| 2209 | + assert.equal(renamed.status, 303); | |
| 2210 | + try { | |
| 2211 | + assert.equal((await ok("GET", "/user", undefined, T.sam)).login, "sam-renamed"); | |
| 2212 | + assert.equal((await pendingAs(T.sam, run4.id)).current_user_can_approve, false); | |
| 2213 | + const self = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.sam); | |
| 2214 | + assert.equal(self.status, 403); | |
| 2215 | + assert.match(self.json.message, /doesn't let whoever started a run approve it/); | |
| 2216 | + } finally { | |
| 2217 | + assert.equal((await submitsAs(S.sam, "/settings", "/settings/profile", { login: "sam", name: "sam" })).status, 303); | |
| 2218 | + } | |
| 2219 | + | |
| 2220 | + // Access counts when deciding, not only when the list was set: on a public repository, where | |
| 2221 | + // anyone may read, a reviewer removed from it no longer approves. | |
| 2222 | + await ok("PATCH", R, { visibility: "public" }); | |
| 2223 | + await status(204, "DELETE", `${R}/collaborators/rita`); | |
| 2224 | + assert.equal((await pendingAs(T.rita, run4.id)).current_user_can_approve, false); | |
| 2225 | + const removed = await api("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "approved" }, T.rita); | |
| 2226 | + assert.equal(removed.status, 403); | |
| 2227 | + assert.match(removed.json.message, /only people with access to this repository/); | |
| 2228 | + await ok("POST", `${R}/actions/runs/${run4.id}/pending_deployments`, { environment_ids: [env4], state: "rejected", comment: "cleanup" }); | |
| 2229 | + await ok("PATCH", R, { visibility: "private" }); | |
| 2230 | + await collaborator(R, "rita", "read", T.rita); | |
| 2231 | + | |
| 2232 | + // A wait timer alone holds the job until it passes; nobody skips it. | |
| 2233 | + await ok("PUT", `${R}/environments/production`, { reviewers: [], wait_timer: 1 }); | |
| 2234 | + const file2 = await ok("GET", `${R}/contents/.github/workflows/deploy.yml`); | |
| 2235 | + const third = (await ok("PUT", `${R}/contents/.github/workflows/deploy.yml`, { message: "deploy 3", content: b64(wf(3)), sha: file2.sha })).commit.sha; | |
| 2236 | + const run3 = await waitingRun(third); | |
| 2237 | + const timer = await pendingAs(T.rita, run3.id); | |
| 2238 | + assert.equal(timer.wait_timer, 1); | |
| 2239 | + assert.equal(timer.current_user_can_approve, false); | |
| 2240 | + await status(422, "POST", `${R}/actions/runs/${run3.id}/pending_deployments`, { environment_ids: [timer.environment.id], state: "approved" }, T.rita); | |
| 2241 | + const started = Date.now(); | |
| 2242 | + // Real time passes here, so give the run's Workflow room on a busy machine: sit out the timer, | |
| 2243 | + // then check the job every few seconds rather than polling the runner queue throughout. | |
| 2244 | + const due = Date.parse(timer.wait_timer_started_at) + 60_000; | |
| 2245 | + await sleep(Math.max(0, due - Date.now())); | |
| 2246 | + for (let end = Date.now() + 300_000; Date.now() < end; await sleep(3_000)) { | |
| 2247 | + if ((await ok("GET", `${R}/actions/runs/${run3.id}/jobs`)).jobs[0].status !== "waiting") break; | |
| 2248 | + } | |
| 2249 | + const timed = await until("the job after its wait timer", claim, 30_000); | |
| 2250 | + assert.ok(Date.now() - started > 20_000, "the job waited for its timer"); | |
| 2251 | + assert.equal(timed.secrets.DEPLOY_KEY, "deploy-value"); | |
| 2252 | +}); | |
| 2253 | + | |
| 2107 | 2254 | test("security: adding someone by username sends an invitation, and they get access only once they accept", async () => { |
| 2108 | 2255 | // ------------------------------------------------------------ a repository: milo is invited, not added |
| 2109 | 2256 | await repoWithReadme("by-name"); |
modifiedapps/diffinery/test/unit/today.test.ts+8−0View file
| @@ -76,6 +76,14 @@ | ||
| 76 | 76 | expect(approvalCandidates(repos, ME)).toEqual(["a"]); |
| 77 | 77 | }); |
| 78 | 78 | |
| 79 | + it("lists deployments waiting for the viewer as a required reviewer, unless they started the run and self-review is off", () => { | |
| 80 | + const d = (runId: string, reviewerIds: string[], selfBlocked: string[] = [], since = "2026-10-13T08:00:00Z") => ({ runId, runNumber: 1, workflow: "deploy", title: "", ref: "refs/heads/main", environment: "production", reviewerIds, selfBlocked, actor: BOB, since }); | |
| 81 | + const deployments = [d("r1", ["u-me"]), d("r2", ["u-bob"]), d("r3", ["u-me"], ["u-me"]), d("r4", ["u-me", "u-bob"], [], "2026-10-13T09:00:00Z"), d("r5", ["u-me"], [BOT.id]), d("r6", ["u-me"], [OTHER_BOT.id], "2026-10-13T07:00:00Z")]; | |
| 82 | + const t = classify({ ...input([{ repo: repo("web"), data: data({ deployments }) }, { repo: repo("old"), data: data({}) }]), ownAgentIds: new Set([BOT.id]) }); | |
| 83 | + // r3 the viewer started, r5 their own agent did; r6's agent belongs to their organization, not them. | |
| 84 | + expect(t.deployments.map((x) => x.deployment.runId)).toEqual(["r4", "r1", "r6"]); | |
| 85 | + }); | |
| 86 | + | |
| 79 | 87 | it("flags the viewer's own blocked pull requests", () => { |
| 80 | 88 | const mine = { ...ME, kind: "user" }; |
| 81 | 89 | const t = classify(input([{ repo: repo("web"), data: data({ pulls: [pull({ number: 1, author: mine, changesRequestedBy: ["bob"] }), pull({ number: 2, author: mine, checks: "failure" }), pull({ number: 3, author: mine })] }) }])); |