Older runners' artifacts in the REST API, cache eviction, Link headers; repo variables and milestones fixes (#47)
Older runners' artifacts are listed, fetched, deleted, counted and expired through the REST API; their cache drops entries unused for 7 days; artifact and cache lists send Link headers; artifact, cache, workflow-settings and organization variable changes made through the API are audited with via: api. Repository variable endpoints see only the repository's own variables, and DELETE ignores letter case. Deleting an organization variable that doesn't exist is a 404. Organization variable changes reach every repository even when one fails. An issue edit's milestone is checked before anything is saved and the response shows it; issue lists filter by milestone, milestone links open that list, and POST milestones honours state. Claude-Session: https://claude.ai/code/session_01QMFviVKD61QaDPxdrh7C3f
Canberk Demirci authored · GitHub committed signed1 parent 4ac92f2commit ff1416cd6df3be894abca667e019baee0cdc02fbBrowse files
24 files changed+550−148
Files (24)
- apps/diffinery/docs/actions/caching-and-artifacts.md+1−1
- apps/diffinery/docs/actions/secrets-and-variables.md+6−11
- apps/diffinery/docs/collaboration/issues.md+9−8
- apps/diffinery/docs/reference/rest-api.md+1−1
- apps/diffinery/docs/security/audit-log.md+1−1
- apps/diffinery/src/docs/api-meta.ts+10−9
- apps/diffinery/src/http/actions-runtime.ts+24−25
- apps/diffinery/src/http/api.ts+17−4
- apps/diffinery/src/http/imports.ts+6−21
- apps/diffinery/src/http/misc.ts+5−3
- apps/diffinery/src/http/runner.ts+3−3
- apps/diffinery/src/lib/directory.ts+12−3
- apps/diffinery/src/repo/RepoDO.ts+3−3
- apps/diffinery/src/repo/actions.ts+41−27
- apps/diffinery/src/repo/collab.ts+7−0
- apps/diffinery/src/repo/imports.ts+13−1
- apps/diffinery/src/repo/runtime.ts+64−14
- apps/diffinery/src/repo/schema.ts+12−0
- apps/diffinery/src/repo/token-policy.ts+3−2
- apps/diffinery/src/web/collab.tsx+14−4
- apps/diffinery/test/e2e/e2e.test.mjs+62−0
- apps/diffinery/test/e2e/security.test.mjs+69−2
- apps/diffinery/test/unit/actions-runtime.test.ts+123−5
- apps/diffinery/test/unit/org-variables.test.ts+44−0
modifiedapps/diffinery/docs/actions/caching-and-artifacts.md+1−1View file
| @@ -461,7 +461,7 @@ | ||
| 461 | 461 | | `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip` | `302 Found` to a signed download URL valid for 10 minutes. `410 Artifact has expired` for an expired artifact. | |
| 462 | 462 | | `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Deletes the artifact. `204 No Content`. Recorded in the [audit log](/docs/security/audit-log) as `actions.artifact.delete`. | |
| 463 | 463 | |
| 464 | -Unknown ids answer `404 Not Found`. An artifact looks like this: | |
| 464 | +Both lists send a `Link` header, so `octokit.paginate` works. Artifacts uploaded by older Diffinery runners are listed, fetched and deleted the same way and kept for 90 days; their download is a gzip-compressed tar file (`<name>.tar.gz`) rather than a zip. Unknown ids answer `404 Not Found`. An artifact looks like this: | |
| 465 | 465 | |
| 466 | 466 | ```json |
| 467 | 467 | { |
modifiedapps/diffinery/docs/actions/secrets-and-variables.md+6−11View file
| @@ -134,7 +134,7 @@ | ||
| 134 | 134 | -d '{"name": "DEPLOY_REGION", "value": "eu-west-1"}' |
| 135 | 135 | ``` |
| 136 | 136 | |
| 137 | -> **Note:** Variable names are stored in uppercase, but the API's `DELETE` matches the name exactly. Send the uppercase name (`DEPLOY_REGION`), or the request returns 204 without deleting anything. | |
| 137 | +Variable names are stored in uppercase, and every endpoint, `DELETE` included, matches them without regard to letter case. | |
| 138 | 138 | |
| 139 | 139 | ## Organization variables |
| 140 | 140 | |
| @@ -159,7 +159,7 @@ | ||
| 159 | 159 | | `GET /api/v3/orgs/{org}/actions/variables/{name}` | Returns one variable in the same shape, or `404 variable not found`. The name is matched case-insensitively. | |
| 160 | 160 | | `POST /api/v3/orgs/{org}/actions/variables` with `{"name": "...", "value": "..."}` | Creates a variable, or replaces the value of an existing one. Returns `201` with an empty body. | |
| 161 | 161 | | `PATCH /api/v3/orgs/{org}/actions/variables/{name}` with `{"value": "..."}` | Sets the value, creating the variable if it doesn't exist. Returns `204`. | |
| 162 | -| `DELETE /api/v3/orgs/{org}/actions/variables/{name}` | Deletes the variable (case-insensitive name). Returns `204`, also when there was no such variable. | | |
| 162 | +| `DELETE /api/v3/orgs/{org}/actions/variables/{name}` | Deletes the variable (case-insensitive name). Returns `204`, or `404 variable not found` when there's no such variable. | | |
| 163 | 163 | |
| 164 | 164 | ```sh |
| 165 | 165 | # Create or replace |
| @@ -188,13 +188,8 @@ | ||
| 188 | 188 | |
| 189 | 189 | ### Organization and repository variables with the same name |
| 190 | 190 | |
| 191 | -A repository's own variable list (the **Variables** section of **Settings → Secrets and variables**, `diffinery variable list`, and `GET /api/v3/repos/{owner}/{repo}/actions/variables`) shows only the repository's own variables, never the organization's. The single-variable repository endpoints do see organization variables: | |
| 191 | +A repository's variables (the **Variables** section of **Settings → Secrets and variables**, `diffinery variable list`, and the `/api/v3/repos/{owner}/{repo}/actions/variables` endpoints) are only the repository's own, never the organization's. `GET` and `PATCH` on a name only the organization has answer `404 Not Found`, and `POST` creates a repository variable even when the organization has one of that name; in workflows, the repository's value wins. `DELETE` on a repository variable never removes an organization variable. | |
| 192 | 192 | |
| 193 | -- `GET /api/v3/repos/{owner}/{repo}/actions/variables/{name}` returns the organization variable's value when the repository has no variable of that name. Anyone with the write role on the repository can read it this way. | |
| 194 | -- `POST /api/v3/repos/{owner}/{repo}/actions/variables` fails with `409 Already exists - Variable already exists` for a name the organization already has. To give one repository its own value, use `PUT /api/v3/repos/{owner}/{repo}/actions/variables/{name}`, `diffinery variable set`, or the web form instead. | |
| 195 | -- `PATCH /api/v3/repos/{owner}/{repo}/actions/variables/{name}` for a name only the organization has creates a repository variable with the new value. | |
| 196 | -- `DELETE` on a repository variable never removes an organization variable. | |
| 197 | - | |
| 198 | 193 | ## Environment secrets and variables |
| 199 | 194 | |
| 200 | 195 | An [environment](/docs/actions/environments) such as `production` can have its own secrets and variables. Only jobs that name the environment with `environment:` receive them: |
| @@ -284,7 +279,7 @@ | ||
| 284 | 279 | 2. Repository variables. |
| 285 | 280 | 3. Organization variables (repositories owned by an organization only). |
| 286 | 281 | |
| 287 | -In places evaluated before a job starts, such as `jobs.<id>.if` and `runs-on`, `vars` holds only repository and organization variables; environment variables are added when the job starts. A change to an organization variable reaches the organization's repositories right away, so jobs that start afterwards see the new value. | |
| 282 | +In places evaluated before a job starts, such as `jobs.<id>.if` and `runs-on`, `vars` holds only repository and organization variables; environment variables are added when the job starts. A change to an organization variable reaches the organization's repositories right away, so jobs that start afterwards see the new value. If some repository can't be updated, the others still are and the request fails with `503`; repeat it to finish. | |
| 288 | 283 | |
| 289 | 284 | ### Which runs receive secrets |
| 290 | 285 | |
| @@ -392,7 +387,7 @@ | ||
| 392 | 387 | - Creating, changing and deleting repository variables (`POST`, `PATCH`, `PUT` and `DELETE` on `/api/v3/repos/{owner}/{repo}/actions/variables`) and environment variables refuse an agent's token the same way, with `403 agents cannot manage variables` for an agent with the admin role. |
| 393 | 388 | - The organization variable endpoints (`/api/v3/orgs/{org}/actions/variables`, listing included) return `403 organization admins only` for any agent's token, or `404 not found` when neither the agent nor its owner belongs to the organization. A GitHub import started by an agent skips variables, secret names and environments. |
| 394 | 389 | - Agents can't create, change or delete environments or their branch policies (`403 only repository admins (people, not agents) can manage environments` for an agent with the admin role). |
| 395 | -- Listing repository secret names and environment secret names needs the admin role; listing and reading variables needs the write role, so an agent with write access can read variable values, including an organization variable's value through `GET /api/v3/repos/{owner}/{repo}/actions/variables/{name}`. An agent has the write role unless a person gave it more (its **Maximum role**, or an explicit admin role on the repository). | |
| 390 | +- Listing repository secret names and environment secret names needs the admin role; listing and reading variables needs the write role, so an agent with write access can read the repository's variable values (organization variables are readable only by organization owners and admins). An agent has the write role unless a person gave it more (its **Maximum role**, or an explicit admin role on the repository). | |
| 396 | 391 | |
| 397 | 392 | Agents can still push branches and open pull requests, and the workflows that run for them receive repository and organization secrets (see [Which runs receive secrets](#which-runs-receive-secrets)). Treat write access for an agent as access to those secrets. Environment secrets are different: runs whose code came from an agent only get them from environments set to protected branches only, and never on pull request refs. In those runs the job token also acts as an agent, so it can't push to protected or default branches. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent) and [Agents](/docs/agents/overview). |
| 398 | 393 | |
| @@ -410,7 +405,7 @@ | ||
| 410 | 405 | | Create, update, rename or delete an environment variable | `environment.variable.set` (a rename adds `renamed_from`), `environment.variable.delete` | |
| 411 | 406 | | Change an environment or its branch policies | `environment.update`, `environment.delete`, `environment.branch_policy.create`, `environment.branch_policy.delete` | |
| 412 | 407 | |
| 413 | -Repository and organization secret changes made with the API or the CLI include `"via": "api"` in the entry's details; changes made on the settings page don't, and neither do organization variable changes. When a GitHub organization import adds organization variables, each one is recorded as `org.variable_set` by the person who started the import, with the GitHub organization as the target and the import's id, the Diffinery organization (`org`) and `name` in the details. See [Audit log](/docs/security/audit-log). | |
| 408 | +Repository and organization secret changes made with the API or the CLI include `"via": "api"` in the entry's details; changes made on the settings page don't. Organization variable changes made with the API include it too. When a GitHub organization import adds organization variables, each one is recorded as `org.variable_set` by the person who started the import, with the GitHub organization as the target and the import's id, the Diffinery organization (`org`) and `name` in the details. See [Audit log](/docs/security/audit-log). | |
| 414 | 409 | |
| 415 | 410 | ## Limits |
| 416 | 411 |
modifiedapps/diffinery/docs/collaboration/issues.md+9−8View file
| @@ -225,7 +225,7 @@ | ||
| 225 | 225 | | `number` (also `id`) | The milestone's number in the repository | |
| 226 | 226 | | `title` | 1 to 255 characters; required when you create a milestone | |
| 227 | 227 | | `description` | Text up to 65,536 characters; empty by default | |
| 228 | -| `state` | `open` or `closed`. New milestones are always `open`. | | |
| 228 | +| `state` | `open` or `closed`. New milestones are `open` unless you create them with `"state": "closed"`. | | |
| 229 | 229 | | `due_on` | An ISO 8601 timestamp, or `null` for no due date. It's stored in UTC. | |
| 230 | 230 | | `creator` | `{"login": "..."}` of the account that created it | |
| 231 | 231 | | `open_issues`, `closed_issues` | How many open and closed issues and pull requests have this milestone (merged pull requests count as closed) | |
| @@ -237,7 +237,7 @@ | ||
| 237 | 237 | | --- | --- | --- | |
| 238 | 238 | | `GET /repos/{owner}/{repo}/milestones` | read | Lists milestones. `state` is `open` (default), `closed` or `all`. Sorted by due date (milestones without one last), then by number. The list isn't paged. | |
| 239 | 239 | | `GET /repos/{owner}/{repo}/milestones/{number}` | read | One milestone, or `404 milestone not found` | |
| 240 | -| `POST /repos/{owner}/{repo}/milestones` | triage | Creates a milestone from `title`, `description` and `due_on`; returns `201` with the milestone. A `state` in the body is ignored. | | |
| 240 | +| `POST /repos/{owner}/{repo}/milestones` | triage | Creates a milestone from `title`, `description`, `state` and `due_on`; returns `201` with the milestone | | |
| 241 | 241 | | `PATCH /repos/{owner}/{repo}/milestones/{number}` | triage | Changes any of `title`, `description`, `state` and `due_on` (`null` removes the due date); returns the milestone | |
| 242 | 242 | |
| 243 | 243 | ```sh |
| @@ -260,8 +260,8 @@ | ||
| 260 | 260 | |
| 261 | 261 | ### Set the milestone of an issue or pull request |
| 262 | 262 | |
| 263 | -- **Web UI (triage or higher):** the sidebar of an issue or pull request has a **Milestone** section showing the milestone's title, a `closed` badge if it's closed and its due date, or **No milestone**. Click **Set milestone**, choose a milestone in the **Milestone** list (it offers the open milestones and **No milestone**), and click **Save**. **Set milestone** appears only when the repository has an open milestone or the issue already has one. | |
| 264 | -- **REST API (triage or higher):** send `milestone` with the milestone's number, or `null` to remove it, on `PATCH /repos/{owner}/{repo}/issues/{number}`. This works for pull request numbers too, and also accepts closed milestones. | |
| 263 | +- **Web UI (triage or higher):** the sidebar of an issue or pull request has a **Milestone** section showing the milestone's title, a `closed` badge if it's closed and its due date, or **No milestone**. Click **Set milestone**, choose a milestone in the **Milestone** list (it offers the open milestones and **No milestone**), and click **Save**. **Set milestone** appears only when the repository has an open milestone or the issue already has one. Click the milestone's title to list its open issues (or pull requests, on a pull request). | |
| 264 | +- **REST API (triage or higher):** send `milestone` with the milestone's number, or `null` to remove it, on `PATCH /repos/{owner}/{repo}/issues/{number}`. This works for pull request numbers too, and also accepts closed milestones. From someone without triage access, such as an issue's author, `milestone` is ignored without an error, like `labels` and `assignees`. | |
| 265 | 265 | |
| 266 | 266 | ```sh |
| 267 | 267 | curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/12 \ |
| @@ -271,13 +271,13 @@ | ||
| 271 | 271 | |
| 272 | 272 | Issues and pull requests returned by the REST API carry a `milestone` object (`number`, `id`, `title`, `state`, `due_on`, `url`; `description` is always `null` there), or `null`. Things to know about setting it with `PATCH`: |
| 273 | 273 | |
| 274 | -- The response shows the issue as it was before the milestone changed. Read the issue again (`GET .../issues/{number}`) to see the new milestone. | |
| 275 | -- The other fields in the same request are saved first. If you're the issue's author without triage access, or the milestone doesn't exist, the title, body, state, labels and assignees you sent are saved and the request then fails with `403 triage access required` or `404 milestone not found`. Send `milestone` in a request of its own. | |
| 274 | +- The response shows the issue with its new milestone. | |
| 275 | +- The milestone is checked before anything is saved: a number no milestone has fails with `422 Validation Failed`, and nothing in the request is saved. | |
| 276 | 276 | - A value that isn't a whole number or `null` fails with `422 milestone must be a milestone number or null`, and nothing is saved. |
| 277 | 277 | |
| 278 | 278 | A workflow's `GITHUB_TOKEN` needs `issues: write` or `pull-requests: write` to create, edit or set milestones. |
| 279 | 279 | |
| 280 | -Setting or changing a milestone doesn't create an event in the timeline, doesn't send a webhook or a notification, and there are no `milestone` webhook events. You can't filter issue lists or searches by milestone yet: `milestone` on `GET .../issues` is ignored, and `milestone:` in a search is searched as text. | |
| 280 | +Setting or changing a milestone doesn't create an event in the timeline, doesn't send a webhook or a notification, and there are no `milestone` webhook events. To list a milestone's issues and pull requests, send `milestone` on `GET .../issues`: a milestone number, `none` (no milestone) or `*` (any milestone). In the web UI, add `milestone=` and the number to the issue list's address; a milestone's `html_url` (`/{owner}/{repo}/milestone/{number}`) opens that list. `milestone:` in a search is still searched as text. | |
| 281 | 281 | |
| 282 | 282 | ## Comments |
| 283 | 283 | |
| @@ -474,6 +474,7 @@ | ||
| 474 | 474 | - **Search:** the search box (press `/` to focus it) matches text anywhere in the title or the description, up to 200 characters. |
| 475 | 475 | - **Label:** pick one from the **Label ▾** menu, or click a label on any issue. |
| 476 | 476 | - **Author:** click an author's login on any issue to show only their issues. |
| 477 | +- **Milestone:** click the milestone in an issue's sidebar, or add `milestone=` and its number to the address. | |
| 477 | 478 | - Active filters appear at the top of the list with a **clear** link. Filters combine. |
| 478 | 479 | |
| 479 | 480 | Filters are query parameters, so you can bookmark or share them: |
| @@ -570,7 +571,7 @@ | ||
| 570 | 571 | - An issue's `id` is its number; issues are addressed by number only. |
| 571 | 572 | - `/search/issues` covers at most 50 repositories per search and ignores qualifiers it doesn't know (they are searched as text). `review-requested:`, `mentions:`, `commenter:`, `milestone:`, `linked:` and `reactions:` are not supported. |
| 572 | 573 | - There are no issue types, issue templates, transfers or pinned issues. |
| 573 | -- Milestones are created and edited through the REST API only, can't be deleted, and have no events, webhooks or list filters. See [Milestones](#milestones). | |
| 574 | +- Milestones are created and edited through the REST API only, can't be deleted, and have no events or webhooks. See [Milestones](#milestones). | |
| 574 | 575 | - Deleting a label in the web UI keeps its name on issues; deleting it through the API removes it from them. |
| 575 | 576 | - Locking, reactions, events and comment deletion are available through the REST API only, not in the web UI. |
| 576 | 577 | - `GET /repos/{owner}/{repo}/assignees` lists direct collaborators only, not organization members. |
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
| @@ -74,7 +74,7 @@ | ||
| 74 | 74 | | Release | `latest`, whether it is the repository's latest release | |
| 75 | 75 | | Pull request file | `binary` | |
| 76 | 76 | |
| 77 | -Fields that describe GitHub features Diffinery doesn't have are present with fixed values, so typed clients don't break: for example `fork: false`, `forks_count: 0`, `stargazers_count: 0`, `watchers_count: 0`, `language: null`, `license: null`, `has_wiki: false` and `milestone: null`. | |
| 77 | +Fields that describe GitHub features Diffinery doesn't have are present with fixed values, so typed clients don't break: for example `fork: false`, `forks_count: 0`, `stargazers_count: 0`, `watchers_count: 0`, `language: null`, `license: null` and `has_wiki: false`. | |
| 78 | 78 | |
| 79 | 79 | ### IDs |
| 80 | 80 |
modifiedapps/diffinery/docs/security/audit-log.md+1−1View file
| @@ -77,7 +77,7 @@ | ||
| 77 | 77 | | `org.update` | An organization's settings are changed on its **Settings** tab, or with `PATCH /api/v3/orgs/{org}` | the organization's login | `login` and `base_role` (web UI), or `name` and `base_role` (API) | |
| 78 | 78 | | `org.delete` | An organization is deleted (web or `DELETE /api/v3/orgs/{org}`) | the organization's login | none | |
| 79 | 79 | | `org.secret.set`, `org.secret.delete` | An organization secret is saved or deleted with `PUT`/`DELETE /api/v3/orgs/{org}/actions/secrets/{name}` (or `diffinery secret ... --org`) | the organization's login | `name` (never the value), `via: "api"` | |
| 80 | -| `org.variable_set`, `org.variable_delete` | An organization Actions variable is created, changed or deleted with `POST`, `PATCH` or `DELETE /api/v3/orgs/{org}/actions/variables[/{name}]`, or added by a GitHub import of an organization | the organization's login (API), or the GitHub organization (import) | `name` (upper-cased); an import adds `import` (its id) and `org` | | |
| 80 | +| `org.variable_set`, `org.variable_delete` | An organization Actions variable is created, changed or deleted with `POST`, `PATCH` or `DELETE /api/v3/orgs/{org}/actions/variables[/{name}]`, or added by a GitHub import of an organization | the organization's login (API), or the GitHub organization (import) | `name` (upper-cased); `via: "api"` for API changes; an import adds `import` (its id) and `org` | | |
| 81 | 81 | | `org.invite` | Someone without an account is invited to an organization: an email address entered in **Add people** on the organization's **People** tab, or `POST /api/v3/orgs/{org}/invitations` | the organization's login | `email`, `role` | |
| 82 | 82 | | `org.invite.revoke` | A pending organization invitation is revoked with **Revoke** | the organization's login | `id` (the invitation id) | |
| 83 | 83 | | `org.member.set`, `org.member.remove` | Someone with an account is added with **Add people**, a member's role is changed, or a member is removed | the organization's login | `user`, `role` | |
modifiedapps/diffinery/src/docs/api-meta.ts+10−9View file
| @@ -527,7 +527,7 @@ | ||
| 527 | 527 | summary: "List issues and pull requests", |
| 528 | 528 | description: "As on GitHub, this list includes pull requests. The total is in the `X-Total-Count` header.", |
| 529 | 529 | access: "read", |
| 530 | - query: { state: ["string", "`open` (default), `closed` or `all`."], labels: ["string", "Only items with this label."], creator: ["string", "Only items opened by this login."], q: ["string", "Text search in titles."], ...page }, | |
| 530 | + query: { state: ["string", "`open` (default), `closed` or `all`."], labels: ["string", "Only items with this label."], creator: ["string", "Only items opened by this login."], milestone: ["string", "A milestone number, `none` (no milestone) or `*` (any milestone)."], q: ["string", "Text search in titles."], ...page }, | |
| 531 | 531 | }, |
| 532 | 532 | "POST /repos/{owner}/{repo}/issues": { |
| 533 | 533 | summary: "Create an issue", |
| @@ -539,7 +539,7 @@ | ||
| 539 | 539 | "GET /repos/{owner}/{repo}/issues/{number}": { summary: "Get an issue", access: "read" }, |
| 540 | 540 | "PATCH /repos/{owner}/{repo}/issues/{number}": { |
| 541 | 541 | summary: "Update an issue", |
| 542 | - description: "The author can edit their own issue or pull request. As on GitHub, `labels` and `assignees` from a caller without triage access are dropped without an error.", | |
| 542 | + description: "The author can edit their own issue or pull request. As on GitHub, `labels`, `assignees` and `milestone` from a caller without triage access are dropped without an error.", | |
| 543 | 543 | access: "read", |
| 544 | 544 | body: { |
| 545 | 545 | title: ["string", "New title."], |
| @@ -548,7 +548,7 @@ | ||
| 548 | 548 | state_reason: ["string", "Why it was closed, like `completed` or `not_planned`."], |
| 549 | 549 | labels: ["string[]", "Replace the labels."], |
| 550 | 550 | assignees: ["string[]", "Replace the assignees."], |
| 551 | - milestone: ["integer", "Milestone number, or `null` to remove the milestone. Needs triage access; applied only when the rest of the update succeeds. 422 when it isn't a whole number or `null`, 404 `milestone not found` for an unknown number."], | |
| 551 | + milestone: ["integer", "Milestone number, or `null` to remove the milestone. Ignored without triage access. Checked before anything is saved: 422 when it isn't a whole number or `null`, or when no milestone has that number."], | |
| 552 | 552 | }, |
| 553 | 553 | }, |
| 554 | 554 | "GET /repos/{owner}/{repo}/milestones": { |
| @@ -560,12 +560,13 @@ | ||
| 560 | 560 | "GET /repos/{owner}/{repo}/milestones/{number}": { summary: "Get a milestone", description: "`number` is the milestone number. 404 `milestone not found`.", access: "read" }, |
| 561 | 561 | "POST /repos/{owner}/{repo}/milestones": { |
| 562 | 562 | summary: "Create a milestone", |
| 563 | - description: "New milestones are open; close one with `PATCH`. There is no endpoint to delete a milestone.", | |
| 563 | + description: "New milestones are open unless `state` is `closed`. There is no endpoint to delete a milestone.", | |
| 564 | 564 | access: "triage", |
| 565 | 565 | status: 201, |
| 566 | 566 | body: { |
| 567 | 567 | title: ["string", "1 to 255 characters.", true], |
| 568 | 568 | description: ["string", "At most 65,536 characters."], |
| 569 | + state: ["string", "`open` (default) or `closed`."], | |
| 569 | 570 | due_on: ["string", "ISO 8601 timestamp, or `null`."], |
| 570 | 571 | }, |
| 571 | 572 | }, |
| @@ -1190,15 +1191,15 @@ | ||
| 1190 | 1191 | }, |
| 1191 | 1192 | "POST /repos/{owner}/{repo}/actions/variables": { |
| 1192 | 1193 | summary: "Create a repository variable", |
| 1193 | - description: "409 when it exists. Names are stored upper-cased. People only. Audited as `repo.variable.set`.", | |
| 1194 | + description: "409 when the repository already has a variable with this name. An organization variable of the same name doesn't count, and the repository's wins in workflows. Names are stored upper-cased. People only. Audited as `repo.variable.set`.", | |
| 1194 | 1195 | access: "admin", |
| 1195 | 1196 | status: 201, |
| 1196 | 1197 | body: { name: ["string", "Variable name.", true], value: ["string", "Variable value.", true] }, |
| 1197 | 1198 | }, |
| 1198 | - "GET /repos/{owner}/{repo}/actions/variables/{name}": { summary: "Get a repository variable", description: "`created_at` and `updated_at` are always null.", access: "write" }, | |
| 1199 | + "GET /repos/{owner}/{repo}/actions/variables/{name}": { summary: "Get a repository variable", description: "404 for a name only the organization has. `created_at` and `updated_at` are always null.", access: "write" }, | |
| 1199 | 1200 | "PATCH /repos/{owner}/{repo}/actions/variables/{name}": { |
| 1200 | 1201 | summary: "Update a repository variable", |
| 1201 | - description: "People only. Renaming onto a name that exists fails with 409. Audited as `repo.variable.set`.", | |
| 1202 | + description: "404 when the repository has no variable with this name; organization variables aren't changed here. People only. Renaming onto a name that exists fails with 409. Audited as `repo.variable.set`.", | |
| 1202 | 1203 | access: "admin", |
| 1203 | 1204 | status: 204, |
| 1204 | 1205 | body: { name: ["string", "New name (renames the variable)."], value: ["string", "New value."] }, |
| @@ -1212,7 +1213,7 @@ | ||
| 1212 | 1213 | }, |
| 1213 | 1214 | "DELETE /repos/{owner}/{repo}/actions/variables/{name}": { |
| 1214 | 1215 | summary: "Delete a repository variable", |
| 1215 | - description: "People only. `name` must match the stored name exactly, and names are stored upper-cased, so send it in upper case. Audited as `repo.variable.delete`.", | |
| 1216 | + description: "People only. Names are matched without regard to letter case. Audited as `repo.variable.delete`.", | |
| 1216 | 1217 | access: "admin", |
| 1217 | 1218 | status: 204, |
| 1218 | 1219 | }, |
| @@ -1247,7 +1248,7 @@ | ||
| 1247 | 1248 | status: 204, |
| 1248 | 1249 | body: { value: ["string", "New value, at most 49,152 characters.", true] }, |
| 1249 | 1250 | }, |
| 1250 | - "DELETE /orgs/{org}/actions/variables/{name}": { summary: "Delete an organization variable", description: "Answers 204 whether or not the variable existed. People only. Audited as `org.variable_delete`.", access: "org-admin", status: 204 }, | |
| 1251 | + "DELETE /orgs/{org}/actions/variables/{name}": { summary: "Delete an organization variable", description: "404 when the organization has no variable with this name, and nothing is audited. People only. Audited as `org.variable_delete`.", access: "org-admin", status: 204 }, | |
| 1251 | 1252 | |
| 1252 | 1253 | // ------------------------------------------------------------ webhooks |
| 1253 | 1254 | "GET /repos/{owner}/{repo}/hooks": { summary: "List webhooks", description: "Secrets are never returned.", access: "admin" }, |
modifiedapps/diffinery/src/http/actions-runtime.ts+24−25View file
| @@ -4,8 +4,10 @@ | ||
| 4 | 4 | import { blockKey, chunkKey, compose, deleteKeys, finalKey, listStaged, parseBlockList, parseRange, sized, stagingPrefix, type BlobBucket, type ComposeStep } from "../lib/blob-store.js"; |
| 5 | 5 | import { signBlobUrl, verifyBlobUrl, verifyRuntimeToken, type BlobRef, type RuntimeClaims } from "../lib/runtime-auth.js"; |
| 6 | 6 | import { HttpError, newId } from "../lib/util.js"; |
| 7 | -import { MAX_UPLOAD_PART, type ArtifactEntry, type CacheEntry } from "../repo/runtime.js"; | |
| 8 | -import { loadRepo, repoStub } from "./common.js"; | |
| 7 | +import { headFirst } from "../lib/storage-keys.js"; | |
| 8 | +import { MAX_UPLOAD_PART, artifactFileName, artifactObjectKeys, type ArtifactEntry, type CacheEntry } from "../repo/runtime.js"; | |
| 9 | +import { auditRepo, loadRepo, repoStub } from "./common.js"; | |
| 10 | +import { pageOf, paginate, setLinks } from "./gh.js"; | |
| 9 | 11 | |
| 10 | 12 | /** |
| 11 | 13 | * GitHub's Actions runtime services, so the official actions and the toolkit |
| @@ -47,7 +49,6 @@ | ||
| 47 | 49 | return { status: 500, message: "internal error" }; |
| 48 | 50 | } |
| 49 | 51 | |
| 50 | -const blobName = (a: ArtifactEntry) => (a.mimeType === ZIP ? `${a.name}.zip` : a.name); | |
| 51 | 52 | const readUrl = (env: Env, b: BlobRef, fileName?: string) => signBlobUrl(env.MASTER_KEY, origin(env), b, "r", nowS() + READ_URL_TTL_S, fileName); |
| 52 | 53 | |
| 53 | 54 | /** |
| @@ -248,7 +249,7 @@ | ||
| 248 | 249 | const name = artifactName(req); |
| 249 | 250 | const a = (await repoStub(c.env, claims.repo).rtJobArtifacts(claims.job, { name }))[0]; |
| 250 | 251 | if (!a) throw new HttpError(404, `artifact '${name}' not found`); |
| 251 | - return { signed_url: await readUrl(c.env, { repo: claims.repo, kind: "artifact", id: a.id }, blobName(a)) }; | |
| 252 | + return { signed_url: await readUrl(c.env, { repo: claims.repo, kind: "artifact", id: a.id }, artifactFileName(a)) }; | |
| 252 | 253 | }); |
| 253 | 254 | |
| 254 | 255 | twirp("ArtifactService", "DeleteArtifact", async (c, claims, req) => { |
| @@ -474,6 +475,7 @@ | ||
| 474 | 475 | const stub = repoStub(c.env, b.repo); |
| 475 | 476 | const headers: Record<string, string> = { "accept-ranges": "bytes", "x-ms-blob-type": "BlockBlob", "content-type": "application/octet-stream" }; |
| 476 | 477 | let etag: string | null; |
| 478 | + let keys = [finalKey(b)]; | |
| 477 | 479 | if (b.kind === "cache") { |
| 478 | 480 | const e = await stub.rtCacheGet(b.id); |
| 479 | 481 | if (!e) return azureError(404, "BlobNotFound", "The cache entry does not exist."); |
| @@ -482,14 +484,16 @@ | ||
| 482 | 484 | const a = await stub.rtArtifactGet(b.id); |
| 483 | 485 | if (!a) return azureError(404, "BlobNotFound", "The artifact does not exist or has expired."); |
| 484 | 486 | etag = a.etag; |
| 485 | - const name = blobName(a); | |
| 487 | + const name = artifactFileName(a); | |
| 486 | 488 | headers["content-type"] = a.mimeType; |
| 487 | 489 | headers["content-disposition"] = `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}"; filename*=UTF-8''${encodeURIComponent(name)}`; |
| 488 | 490 | if (a.digest) headers["x-ms-meta-digest"] = a.digest; |
| 491 | + // An older runner's tarball stays at its legacy key. | |
| 492 | + keys = artifactObjectKeys(b.repo, a); | |
| 489 | 493 | } |
| 490 | - const key = finalKey(b); | |
| 491 | - const head = await c.env.BUCKET.head(key); | |
| 494 | + const head = await headFirst(c.env.BUCKET, keys); | |
| 492 | 495 | if (!head) return azureError(404, "BlobNotFound", "The specified blob does not exist."); |
| 496 | + const key = head.key; | |
| 493 | 497 | // Only the object that was committed: a write that landed after Finalize is not served. |
| 494 | 498 | if (etag && head.etag !== etag) return azureError(409, "BlobModified", "The blob was modified after it was committed."); |
| 495 | 499 | Object.assign(headers, { etag: head.httpEtag, "last-modified": head.uploaded.toUTCString() }); |
| @@ -515,12 +519,6 @@ | ||
| 515 | 519 | |
| 516 | 520 | const P = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! }); |
| 517 | 521 | |
| 518 | -const paging = (c: AppContext) => { | |
| 519 | - const perPage = Math.min(Math.max(Number(c.req.query("per_page") ?? 30) || 30, 1), 100); | |
| 520 | - const page = Math.max(Number(c.req.query("page") ?? 1) || 1, 1); | |
| 521 | - return { perPage, page }; | |
| 522 | -}; | |
| 523 | - | |
| 524 | 522 | const idParam = (c: AppContext, name: string) => { |
| 525 | 523 | const n = Number(c.req.param(name)); |
| 526 | 524 | if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(404, "Not Found"); |
| @@ -550,8 +548,9 @@ | ||
| 550 | 548 | |
| 551 | 549 | actionsRest.get("/repos/:owner/:repo/actions/artifacts", async (c) => { |
| 552 | 550 | const { repo, stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 553 | - const { perPage, page } = paging(c); | |
| 554 | - const res = await stub.rtRepoArtifacts({ name: c.req.query("name") || undefined, page, perPage }); | |
| 551 | + const p = pageOf(c); | |
| 552 | + const res = await stub.rtRepoArtifacts({ name: c.req.query("name") || undefined, page: p.page, perPage: p.perPage }); | |
| 553 | + setLinks(c, p, res.total); | |
| 555 | 554 | return c.json({ total_count: res.total, artifacts: res.artifacts.map((a) => artifactJson(c, repo.id, a)) }); |
| 556 | 555 | }); |
| 557 | 556 | |
| @@ -559,9 +558,8 @@ | ||
| 559 | 558 | const { repo, stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 560 | 559 | const runId = c.req.param("runId"); |
| 561 | 560 | if (!/^[A-Za-z0-9]{1,64}$/.test(runId)) throw new HttpError(404, "Not Found"); |
| 562 | - const { perPage, page } = paging(c); | |
| 563 | 561 | const all = await stub.rtRunArtifacts(runId, { name: c.req.query("name") || undefined, includeExpired: true }); |
| 564 | - return c.json({ total_count: all.length, artifacts: all.slice((page - 1) * perPage, page * perPage).map((a) => artifactJson(c, repo.id, a)) }); | |
| 562 | + return c.json({ total_count: all.length, artifacts: paginate(c, all).map((a) => artifactJson(c, repo.id, a)) }); | |
| 565 | 563 | }); |
| 566 | 564 | |
| 567 | 565 | actionsRest.get("/repos/:owner/:repo/actions/artifacts/:id", async (c) => { |
| @@ -578,21 +576,22 @@ | ||
| 578 | 576 | if (!a) throw new HttpError(404, "Not Found"); |
| 579 | 577 | // As on GitHub: an expired artifact is still listed, but its content is gone. |
| 580 | 578 | if (a.expired) throw new HttpError(410, "Artifact has expired"); |
| 581 | - return c.redirect(await signBlobUrl(c.env.MASTER_KEY, origin(c.env), { repo: repo.id, kind: "artifact", id: a.id }, "r", nowS() + 600, blobName(a)), 302); | |
| 579 | + return c.redirect(await signBlobUrl(c.env.MASTER_KEY, origin(c.env), { repo: repo.id, kind: "artifact", id: a.id }, "r", nowS() + 600, artifactFileName(a)), 302); | |
| 582 | 580 | }); |
| 583 | 581 | |
| 584 | 582 | actionsRest.delete("/repos/:owner/:repo/actions/artifacts/:id", async (c) => { |
| 585 | 583 | const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 586 | 584 | const deleted = await stub.rtArtifactDelete(idParam(c, "id")); |
| 587 | 585 | if (!deleted) throw new HttpError(404, "Not Found"); |
| 588 | - c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action: "actions.artifact.delete", target: `${repo.owner_login}/${repo.name}`, detail: { id: deleted.id, name: deleted.name } })); | |
| 586 | + auditRepo(c, caller, repo, "actions.artifact.delete", { id: deleted.id, name: deleted.name }); | |
| 589 | 587 | return c.body(null, 204); |
| 590 | 588 | }); |
| 591 | 589 | |
| 592 | 590 | actionsRest.get("/repos/:owner/:repo/actions/caches", async (c) => { |
| 593 | 591 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 594 | - const { perPage, page } = paging(c); | |
| 595 | - const res = await stub.rtListCaches({ key: c.req.query("key") || undefined, ref: c.req.query("ref") || undefined, sort: c.req.query("sort"), direction: c.req.query("direction"), page, perPage }); | |
| 592 | + const p = pageOf(c); | |
| 593 | + const res = await stub.rtListCaches({ key: c.req.query("key") || undefined, ref: c.req.query("ref") || undefined, sort: c.req.query("sort"), direction: c.req.query("direction"), page: p.page, perPage: p.perPage }); | |
| 594 | + setLinks(c, p, res.total); | |
| 596 | 595 | return c.json({ total_count: res.total, actions_caches: res.caches.map(cacheJson) }); |
| 597 | 596 | }); |
| 598 | 597 | |
| @@ -602,7 +601,7 @@ | ||
| 602 | 601 | if (!key) throw new HttpError(422, "key is required"); |
| 603 | 602 | const deleted = await stub.rtDeleteCaches({ key, ref: c.req.query("ref") || undefined }); |
| 604 | 603 | if (!deleted.length) throw new HttpError(404, "Not Found"); |
| 605 | - c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action: "actions.cache.delete", target: `${repo.owner_login}/${repo.name}`, detail: { key, ids: deleted.map((d) => d.id) } })); | |
| 604 | + auditRepo(c, caller, repo, "actions.cache.delete", { key, ids: deleted.map((d) => d.id) }); | |
| 606 | 605 | return c.json({ total_count: deleted.length, actions_caches: deleted.map(cacheJson) }); |
| 607 | 606 | }); |
| 608 | 607 | |
| @@ -610,7 +609,7 @@ | ||
| 610 | 609 | const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 611 | 610 | const deleted = await stub.rtDeleteCaches({ id: idParam(c, "id") }); |
| 612 | 611 | if (!deleted.length) throw new HttpError(404, "Not Found"); |
| 613 | - c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action: "actions.cache.delete", target: `${repo.owner_login}/${repo.name}`, detail: { key: deleted[0]!.key, ids: [deleted[0]!.id] } })); | |
| 612 | + auditRepo(c, caller, repo, "actions.cache.delete", { key: deleted[0]!.key, ids: [deleted[0]!.id] }); | |
| 614 | 613 | return c.body(null, 204); |
| 615 | 614 | }); |
| 616 | 615 | |
| @@ -626,8 +625,8 @@ | ||
| 626 | 625 | const { caller, stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 627 | 626 | const b = (await c.req.json().catch(() => null)) as { default_workflow_permissions?: unknown; can_approve_pull_request_reviews?: unknown } | null; |
| 628 | 627 | if (!b || typeof b !== "object") throw new HttpError(422, "a JSON body is required"); |
| 629 | - // Audited by the repository (an audit event per change). | |
| 630 | - await stub.setWorkflowSettings({ defaultPermissions: b.default_workflow_permissions as "read" | "write" | undefined, canApprovePullRequests: b.can_approve_pull_request_reviews as boolean | undefined }, caller); | |
| 628 | + // Audited by the repository (an audit event per change), marked as made through the API. | |
| 629 | + await stub.setWorkflowSettings({ defaultPermissions: b.default_workflow_permissions as "read" | "write" | undefined, canApprovePullRequests: b.can_approve_pull_request_reviews as boolean | undefined }, caller, { via: "api" }); | |
| 631 | 630 | return c.body(null, 204); |
| 632 | 631 | }); |
| 633 | 632 |
modifiedapps/diffinery/src/http/api.ts+17−4View file
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | import { encryptSecret } from "../lib/crypto.js"; |
| 7 | 7 | import { publicJwk } from "../lib/jwt.js"; |
| 8 | 8 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 9 | -import type { ProtectionRules, Role } from "../repo/types.js"; | |
| 9 | +import { atLeast, type ProtectionRules, type Role } from "../repo/types.js"; | |
| 10 | 10 | import type { JobInfo, RunInfo } from "../repo/actions.js"; |
| 11 | 11 | import type { CommitInfo } from "../repo/RepoDO.js"; |
| 12 | 12 | import type { QueueEntry } from "../repo/queue.js"; |
| @@ -52,7 +52,7 @@ | ||
| 52 | 52 | return n; |
| 53 | 53 | }; |
| 54 | 54 | |
| 55 | -// GitHub imports, milestones, organization variables and linked accounts (mounted first: it extends PATCH /issues/:number). | |
| 55 | +// GitHub imports, milestones, organization variables and linked accounts. | |
| 56 | 56 | api.route("/", imports); |
| 57 | 57 | |
| 58 | 58 | // ---------------------------------------------------------------- meta & users |
| @@ -519,7 +519,11 @@ | ||
| 519 | 519 | const q = (k: string) => c.req.query(k) || undefined; |
| 520 | 520 | const labels = q("labels")?.split(",").map((l) => l.trim()).filter(Boolean); |
| 521 | 521 | const head = q("head"); |
| 522 | + // `milestone`: a milestone number, `none` or `*` (any), as on GitHub. | |
| 523 | + const ms = q("milestone"); | |
| 524 | + if (ms && !/^(\d+|none|\*)$/.test(ms)) throw new HttpError(422, "milestone must be a milestone number, none or *"); | |
| 522 | 525 | const res = await stub.issues({ |
| 526 | + milestone: ms === "none" || ms === "*" ? ms : ms ? Number(ms) : undefined, | |
| 523 | 527 | kind: kind === "pulls" ? "pull" : undefined, |
| 524 | 528 | state: (q("state") as "open" | "closed" | "all") ?? "open", |
| 525 | 529 | labels, |
| @@ -552,8 +556,17 @@ | ||
| 552 | 556 | }); |
| 553 | 557 | api.patch("/repos/:owner/:repo/issues/:number", async (c) => { |
| 554 | 558 | const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 555 | - const b = await body<{ title?: string; body?: string | null; state?: "open" | "closed"; state_reason?: string | null; labels?: unknown; assignees?: unknown; assignee?: unknown }>(c); | |
| 556 | - const issue = await stub.updateIssue(num(c), { title: b.title, body: b.body ?? undefined, state: b.state, stateReason: b.state_reason ?? undefined, labels: labelNames(b.labels), assignees: assigneeLogins(b) }, caller); | |
| 559 | + const b = await body<{ title?: string; body?: string | null; state?: "open" | "closed"; state_reason?: string | null; labels?: unknown; assignees?: unknown; assignee?: unknown; milestone?: unknown }>(c); | |
| 560 | + // `milestone` (a number or null) is checked before anything is saved. As with labels and assignees, | |
| 561 | + // it is silently dropped for people without triage access. | |
| 562 | + if (b.milestone !== undefined && b.milestone !== null && (typeof b.milestone !== "number" || !Number.isInteger(b.milestone))) throw new HttpError(422, "milestone must be a milestone number or null"); | |
| 563 | + const milestone = atLeast(caller.role, "triage") ? (b.milestone as number | null | undefined) : undefined; | |
| 564 | + if (typeof milestone === "number" && !(await stub.milestones("all")).some((m) => m.number === milestone)) throw new HttpError(422, "Validation Failed", { errors: [{ resource: "Issue", code: "invalid", field: "milestone" }] }); | |
| 565 | + let issue = await stub.updateIssue(num(c), { title: b.title, body: b.body ?? undefined, state: b.state, stateReason: b.state_reason ?? undefined, labels: labelNames(b.labels), assignees: assigneeLogins(b) }, caller); | |
| 566 | + if (milestone !== undefined) { | |
| 567 | + await stub.setIssueMilestone(num(c), milestone); | |
| 568 | + issue = (await stub.issue(num(c))).issue; | |
| 569 | + } | |
| 557 | 570 | return c.json((await issuesOut(c, repo, stub, [issue]))[0]); |
| 558 | 571 | }); |
| 559 | 572 | api.get("/repos/:owner/:repo/issues/:number/comments", async (c) => { |
modifiedapps/diffinery/src/http/imports.ts+6−21View file
| @@ -292,24 +292,6 @@ | ||
| 292 | 292 | return c.json(milestoneJson(c, await stub.saveMilestone(num(c), milestoneInput(await body<Record<string, unknown>>(c)), caller.login))); |
| 293 | 293 | }); |
| 294 | 294 | |
| 295 | -// `PATCH /issues/:number` with `milestone` (a number or null), as on GitHub. The rest of the | |
| 296 | -// request is handled by the issue route in api.ts, which runs next. | |
| 297 | -imports.patch("/repos/:owner/:repo/issues/:number", async (c, next) => { | |
| 298 | - const b = await body<{ milestone?: unknown }>(c); | |
| 299 | - let milestone: number | null | undefined; | |
| 300 | - if (b.milestone !== undefined) { | |
| 301 | - if (b.milestone !== null && (typeof b.milestone !== "number" || !Number.isInteger(b.milestone))) throw new HttpError(422, "milestone must be a milestone number or null"); | |
| 302 | - milestone = b.milestone as number | null; | |
| 303 | - } | |
| 304 | - // Let the main issue handler (api.ts) validate and apply first; only change the milestone if it succeeded, | |
| 305 | - // so a rejected PATCH never leaves the milestone half-changed. | |
| 306 | - await next(); | |
| 307 | - if (milestone !== undefined && c.res.status < 400) { | |
| 308 | - const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "triage"); | |
| 309 | - await stub.setIssueMilestone(num(c), milestone); | |
| 310 | - } | |
| 311 | -}); | |
| 312 | - | |
| 313 | 295 | // ---------------------------------------------------------------- organization Actions variables |
| 314 | 296 | |
| 315 | 297 | async function orgFor(c: AppContext, admin: boolean) { |
| @@ -339,7 +321,7 @@ | ||
| 339 | 321 | if (!VAR_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid variable name"); |
| 340 | 322 | if (typeof value !== "string" || value.length > 48 * 1024) throw new HttpError(422, "value must be a string of at most 48 KB"); |
| 341 | 323 | await c.get("dir").setOrgVariables(org.id, { [name]: value }); |
| 342 | - c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "org.variable_set", target: org.login, detail: { name: name.toUpperCase() } })); | |
| 324 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "org.variable_set", target: org.login, detail: { name: name.toUpperCase(), via: "api" } })); | |
| 343 | 325 | }; |
| 344 | 326 | |
| 345 | 327 | imports.post("/orgs/:org/actions/variables", async (c) => { |
| @@ -363,7 +345,10 @@ | ||
| 363 | 345 | imports.delete("/orgs/:org/actions/variables/:name", async (c) => { |
| 364 | 346 | requireAccountScope(c, "admin"); |
| 365 | 347 | const { a, org } = await orgFor(c, true); |
| 366 | - await c.get("dir").setOrgVariables(org.id, { [c.req.param("name")]: null }); | |
| 367 | - c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "org.variable_delete", target: org.login, detail: { name: c.req.param("name").toUpperCase() } })); | |
| 348 | + const name = c.req.param("name").toUpperCase(); | |
| 349 | + // Like GitHub: deleting a variable that doesn't exist is a 404, and nothing is audited. | |
| 350 | + if (!(await c.get("dir").orgVariables(org.id)).some((v) => v.name === name)) throw new HttpError(404, "variable not found"); | |
| 351 | + await c.get("dir").setOrgVariables(org.id, { [name]: null }); | |
| 352 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "org.variable_delete", target: org.login, detail: { name, via: "api" } })); | |
| 368 | 353 | return c.body(null, 204); |
| 369 | 354 | }); |
modifiedapps/diffinery/src/http/misc.ts+5−3View file
| @@ -117,11 +117,13 @@ | ||
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | 119 | // ---------------------------------------------------------------- repository variables (GitHub's POST/GET/PATCH forms) |
| 120 | +// These see only the repository's own variables: organization variables are read and changed under | |
| 121 | +// /orgs/:org/actions/variables, and a repository variable of the same name overrides one in workflows. | |
| 120 | 122 | |
| 121 | 123 | misc.get(`${R}/actions/variables/:name`, async (c) => { |
| 122 | 124 | const { stub } = await load(c, "write"); |
| 123 | 125 | const name = c.req.param("name").toUpperCase(); |
| 124 | - const vars = await stub.variables(); | |
| 126 | + const vars = await stub.variables(false); | |
| 125 | 127 | if (!(name in vars)) throw new HttpError(404, "Not Found"); |
| 126 | 128 | return c.json({ name, value: vars[name], created_at: null, updated_at: null }); |
| 127 | 129 | }); |
| @@ -130,7 +132,7 @@ | ||
| 130 | 132 | refuseAgent(caller, "variables"); |
| 131 | 133 | const b = await jsonBody<{ name?: string; value?: string }>(c); |
| 132 | 134 | if (typeof b.name !== "string" || typeof b.value !== "string") throw new HttpError(422, "name and value are required", { errors: [{ resource: "ActionsVariable", code: "missing_field", field: typeof b.name !== "string" ? "name" : "value" }] }); |
| 133 | - if (b.name.toUpperCase() in (await stub.variables())) throw new HttpError(409, "Already exists - Variable already exists"); | |
| 135 | + if (b.name.toUpperCase() in (await stub.variables(false))) throw new HttpError(409, "Already exists - Variable already exists"); | |
| 134 | 136 | await stub.setVariable(b.name, b.value); |
| 135 | 137 | auditRepo(c, caller, repo, "repo.variable.set", { name: b.name.toUpperCase() }); |
| 136 | 138 | return c.json({}, 201); |
| @@ -139,7 +141,7 @@ | ||
| 139 | 141 | const { stub, caller, repo } = await load(c, "admin"); |
| 140 | 142 | refuseAgent(caller, "variables"); |
| 141 | 143 | const name = c.req.param("name"); |
| 142 | - const vars = await stub.variables(); | |
| 144 | + const vars = await stub.variables(false); | |
| 143 | 145 | if (!(name.toUpperCase() in vars)) throw new HttpError(404, "Not Found"); |
| 144 | 146 | const b = await jsonBody<{ name?: string; value?: string }>(c); |
| 145 | 147 | const next = typeof b.name === "string" && b.name ? b.name : name; |
modifiedapps/diffinery/src/http/runner.ts+3−3View file
| @@ -111,9 +111,9 @@ | ||
| 111 | 111 | const { token, repoId } = jobAuth(c.req.raw, jobId); |
| 112 | 112 | const stub = repoStub(c.env, repoId); |
| 113 | 113 | // Authenticates the job before anything is written: jobAuth only reads the token's shape. |
| 114 | - const keys = await stub.artifactKeysFor(jobId, token, c.req.param("name")); | |
| 115 | - // What is left of the repository's artifact quota, counting live artifacts from both upload paths. | |
| 116 | - const room = await stub.artifactRoom(); | |
| 114 | + // The artifact is recorded with the artifact service's (numeric ids, retention, the REST API) | |
| 115 | + // but stored at its legacy key; `room` is what is left of the repository's artifact quota. | |
| 116 | + const { r2Keys: keys, room } = await stub.artifactUploadKeys(jobId, token, c.req.param("name")); | |
| 117 | 117 | if (room === 0) throw new HttpError(413, "the repository's artifact storage quota is used up; delete artifacts or wait for them to expire"); |
| 118 | 118 | const size = await storeBody(c, keys[0]!, room); |
| 119 | 119 | await stub.artifactSaved(jobId, token, c.req.param("name"), size); |
modifiedapps/diffinery/src/lib/directory.ts+12−3View file
| @@ -775,11 +775,20 @@ | ||
| 775 | 775 | return { added, skipped }; |
| 776 | 776 | } |
| 777 | 777 | |
| 778 | - /** Copy the organization's current variable set into each of its repositories' Durable Objects. */ | |
| 778 | + /** | |
| 779 | + * Copy the organization's current variable set into each of its repositories' Durable Objects. | |
| 780 | + * Every repository is tried even when one fails, and failures get a second try. Any still failing | |
| 781 | + * make this throw, so the caller can repeat the (idempotent) change: the variables themselves are | |
| 782 | + * already saved, and each copy is the whole set, so a later sync brings a stale repository current. | |
| 783 | + */ | |
| 779 | 784 | private async syncOrgVariables(orgId: string): Promise<void> { |
| 780 | 785 | const vars = Object.fromEntries((await this.orgVariables(orgId)).map((v) => [v.name, v.value])); |
| 781 | - const repos = await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(orgId).all<{ id: string }>(); | |
| 782 | - for (const r of repos.results) await this.env.REPO.get(this.env.REPO.idFromName(r.id)).setOrgVariables(vars); | |
| 786 | + let ids = (await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(orgId).all<{ id: string }>()).results.map((r) => r.id); | |
| 787 | + for (let attempt = 0; attempt < 2 && ids.length; attempt++) { | |
| 788 | + const results = await Promise.allSettled(ids.map((id) => this.env.REPO.get(this.env.REPO.idFromName(id)).setOrgVariables(vars))); | |
| 789 | + ids = ids.filter((_, i) => results[i]!.status === "rejected"); | |
| 790 | + } | |
| 791 | + if (ids.length) throw new HttpError(503, `the variables were saved, but ${ids.length} ${ids.length === 1 ? "repository wasn't" : "repositories weren't"} updated; repeat the request to finish`); | |
| 783 | 792 | } |
| 784 | 793 | |
| 785 | 794 | /** The highest role an agent may ever hold (its policy's maxRole, default write). */ |
modifiedapps/diffinery/src/repo/RepoDO.ts+3−3View file
| @@ -870,6 +870,9 @@ | ||
| 870 | 870 | cacheSaved(...a: Parameters<Actions["cacheSaved"]>) { |
| 871 | 871 | return this.actions.cacheSaved(...a); |
| 872 | 872 | } |
| 873 | + artifactUploadKeys(...a: Parameters<Actions["artifactUploadKeys"]>) { | |
| 874 | + return this.actions.artifactUploadKeys(...a); | |
| 875 | + } | |
| 873 | 876 | artifactSaved(...a: Parameters<Actions["artifactSaved"]>) { |
| 874 | 877 | return this.actions.artifactSaved(...a); |
| 875 | 878 | } |
| @@ -981,9 +984,6 @@ | ||
| 981 | 984 | rtUnstage(...a: Parameters<Runtime["unstage"]>) { |
| 982 | 985 | return this.runtime.unstage(...a); |
| 983 | 986 | } |
| 984 | - artifactRoom() { | |
| 985 | - return this.runtime.artifactRoom(); | |
| 986 | - } | |
| 987 | 987 | rtCheckCommitSize(...a: Parameters<Runtime["checkCommitSize"]>) { |
| 988 | 988 | return this.runtime.checkCommitSize(...a); |
| 989 | 989 | } |
modifiedapps/diffinery/src/repo/actions.ts+41−27View file
| @@ -17,7 +17,6 @@ | ||
| 17 | 17 | } from "@diffinery/workflow-yaml"; |
| 18 | 18 | import { decryptSecret, encryptSecret } from "../lib/crypto.js"; |
| 19 | 19 | import { Directory } from "../lib/directory.js"; |
| 20 | -import { finalKey } from "../lib/blob-store.js"; | |
| 21 | 20 | import { getFirst, headFirst, legacyArtifactKeys, legacyCacheKeys, logKeys } from "../lib/storage-keys.js"; |
| 22 | 21 | import { permissionsForJob, type JobPermissions } from "../lib/job-permissions.js"; |
| 23 | 22 | import { signRuntimeToken } from "../lib/runtime-auth.js"; |
| @@ -26,6 +25,7 @@ | ||
| 26 | 25 | import type { Env } from "../env.js"; |
| 27 | 26 | import type { RepoDO } from "./RepoDO.js"; |
| 28 | 27 | import { completePayload } from "./event-payloads.js"; |
| 28 | +import { CACHE_IDLE_MS, CACHE_LIMIT_BYTES, artifactFileName, artifactObjectKeys } from "./runtime.js"; | |
| 29 | 29 | import { atLeast, type Caller } from "./types.js"; |
| 30 | 30 | import { isJobCaller } from "./token-policy.js"; |
| 31 | 31 | import { ReusableError, expandReusable, localKey, parseCallRef, type Expanded, type Source } from "./reusable.js"; |
| @@ -1156,8 +1156,9 @@ | ||
| 1156 | 1156 | |
| 1157 | 1157 | /** |
| 1158 | 1158 | * What a job token may do with container packages (the `packages` permission). |
| 1159 | - * Like GitHub's permissive default, a workflow without a permissions block gets write; | |
| 1160 | - * a permissions map that leaves `packages` out gets none. | |
| 1159 | + * A workflow without a permissions block gets the repository's default: read in | |
| 1160 | + * repositories with read-only tokens (the default for new ones), write in those that | |
| 1161 | + * keep GitHub's old permissive default. A permissions map that leaves `packages` out gets none. | |
| 1161 | 1162 | */ |
| 1162 | 1163 | async packagesPermission(token: string): Promise<"none" | "read" | "write"> { |
| 1163 | 1164 | const auth = await this.runnerAuth(token); |
| @@ -1382,7 +1383,7 @@ | ||
| 1382 | 1383 | } |
| 1383 | 1384 | |
| 1384 | 1385 | setVariable(name: string, value: string | null): void { |
| 1385 | - if (value === null) this.sql.exec("DELETE FROM variables WHERE name = ?", name); | |
| 1386 | + if (value === null) this.sql.exec("DELETE FROM variables WHERE name = ?", name.toUpperCase()); | |
| 1386 | 1387 | else this.sql.exec("INSERT OR REPLACE INTO variables (name, value, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), value, now()); |
| 1387 | 1388 | } |
| 1388 | 1389 | |
| @@ -1402,7 +1403,7 @@ | ||
| 1402 | 1403 | * (repo/runtime.ts): a job saves to its own ref and restores from its own ref, |
| 1403 | 1404 | * then the pull request's base, then the default branch, so one branch or pull |
| 1404 | 1405 | * request never sees another's caches. Entries saved before scoping (no ref) |
| 1405 | - * are never restored and age out of the budget. | |
| 1406 | + * are never restored, so they go a week after their last use. | |
| 1406 | 1407 | */ |
| 1407 | 1408 | async cacheLookup(jobId: string, token: string, key: string, restoreKeys: string[]): Promise<{ key: string; stored: string } | null> { |
| 1408 | 1409 | await this.authJob(jobId, token); |
| @@ -1455,13 +1456,17 @@ | ||
| 1455 | 1456 | return legacyCacheKeys(this.repo.meta.repoId, key); |
| 1456 | 1457 | } |
| 1457 | 1458 | |
| 1459 | + /** Records a saved entry; returns the R2 keys of the entries it evicted, for the caller to delete. */ | |
| 1458 | 1460 | async cacheSaved(jobId: string, token: string, key: string, size: number): Promise<string[]> { |
| 1459 | 1461 | const { stored } = await this.cacheSaveKeys(jobId, token, key); |
| 1460 | 1462 | this.sql.exec("INSERT OR REPLACE INTO caches (key, size, created_at, last_used) VALUES (?, ?, ?, ?)", stored, size, now(), now()); |
| 1461 | - // Evict least recently used entries above 10 GB per repository. | |
| 1462 | - const evicted: string[] = []; | |
| 1463 | + // Like the cache service (repo/runtime.ts): entries unused for a week go, then the | |
| 1464 | + // least recently used ones above 10 GB per repository. | |
| 1465 | + const stale = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE last_used < ?", new Date(Date.now() - CACHE_IDLE_MS).toISOString()).toArray().map((r) => r.key); | |
| 1466 | + for (const k of stale) this.sql.exec("DELETE FROM caches WHERE key = ?", k); | |
| 1467 | + const evicted = stale.flatMap((k) => this.cacheR2Keys(k)); | |
| 1463 | 1468 | let total = this.sql.exec<{ n: number | null }>("SELECT sum(size) AS n FROM caches").one().n ?? 0; |
| 1464 | - while (total > 10 * 1024 ** 3) { | |
| 1469 | + while (total > CACHE_LIMIT_BYTES) { | |
| 1465 | 1470 | const old = this.sql.exec<{ key: string; size: number }>("SELECT key, size FROM caches ORDER BY last_used LIMIT 1").one(); |
| 1466 | 1471 | this.sql.exec("DELETE FROM caches WHERE key = ?", old.key); |
| 1467 | 1472 | evicted.push(...this.cacheR2Keys(old.key)); |
| @@ -1470,13 +1475,24 @@ | ||
| 1470 | 1475 | return evicted; |
| 1471 | 1476 | } |
| 1472 | 1477 | |
| 1473 | - async artifactSaved(jobId: string, token: string, name: string, size: number): Promise<{ r2Key: string }> { | |
| 1478 | + /** | |
| 1479 | + * Where job `jobId` (authenticated by its token) stores artifact `name` of its run, and how many bytes | |
| 1480 | + * it may store there (what is left of the artifact quota). Refused when the run | |
| 1481 | + * already has an artifact of that name from the artifact service. | |
| 1482 | + */ | |
| 1483 | + async artifactUploadKeys(jobId: string, token: string, name: string): Promise<{ r2Keys: string[]; room: number }> { | |
| 1474 | 1484 | const job = await this.authJob(jobId, token); |
| 1475 | - this.sql.exec("INSERT OR REPLACE INTO artifacts (id, run_id, name, size, created_at) VALUES (?, ?, ?, ?, ?)", newId(), job.run_id, name, size, now()); | |
| 1476 | - return { r2Key: this.artifactR2Keys(job.run_id, name)[0]! }; | |
| 1485 | + this.repo.runtime.legacyArtifactCheck(job.run_id, name); | |
| 1486 | + return { r2Keys: this.artifactR2Keys(job.run_id, name), room: await this.repo.runtime.artifactRoom() }; | |
| 1477 | 1487 | } |
| 1478 | 1488 | |
| 1479 | - /** Where job `jobId` (authenticated by its token) reads or writes artifact `name` of its run. */ | |
| 1489 | + /** Records an older runner's artifact, stored at its legacy key, with the artifact service's (repo/runtime.ts). */ | |
| 1490 | + async artifactSaved(jobId: string, token: string, name: string, size: number): Promise<{ id: number }> { | |
| 1491 | + const job = await this.authJob(jobId, token); | |
| 1492 | + return { id: this.repo.runtime.legacyArtifactSaved(job.run_id, jobId, name, size).id }; | |
| 1493 | + } | |
| 1494 | + | |
| 1495 | + /** Where job `jobId` (authenticated by its token) reads artifact `name` of its run. */ | |
| 1480 | 1496 | async artifactKeysFor(jobId: string, token: string, name: string): Promise<string[]> { |
| 1481 | 1497 | const job = await this.authJob(jobId, token); |
| 1482 | 1498 | return this.artifactR2Keys(job.run_id, name); |
| @@ -1491,22 +1507,20 @@ | ||
| 1491 | 1507 | * A run's artifacts: those uploaded through the artifact service (zip files, or |
| 1492 | 1508 | * single unarchived files) and older runners' tarballs. |
| 1493 | 1509 | */ |
| 1494 | - artifacts(runId: string): Array<{ name: string; size: number; createdAt: string; r2Keys: string[]; id?: number; contentType: string; fileName: string }> { | |
| 1510 | + artifacts(runId: string): Array<{ name: string; size: number; createdAt: string; r2Keys: string[]; id: number; contentType: string; fileName: string }> { | |
| 1495 | 1511 | runId = this.runRow(runId).id; |
| 1496 | - const legacy = this.sql | |
| 1497 | - .exec<{ name: string; size: number; created_at: string }>("SELECT name, size, created_at FROM artifacts WHERE run_id = ? ORDER BY name", runId) | |
| 1498 | - .toArray() | |
| 1499 | - .map((a) => ({ name: a.name, size: a.size, createdAt: a.created_at, r2Keys: this.artifactR2Keys(runId, a.name), contentType: "application/gzip", fileName: `${a.name}.tar.gz` })); | |
| 1500 | - const current = this.repo.runtime.runArtifacts(runId).map((a) => ({ | |
| 1501 | - name: a.name, | |
| 1502 | - size: a.size, | |
| 1503 | - createdAt: a.createdAt, | |
| 1504 | - r2Keys: [finalKey({ repo: this.repo.meta.repoId, kind: "artifact", id: a.id })], | |
| 1505 | - id: a.id, | |
| 1506 | - contentType: a.mimeType, | |
| 1507 | - fileName: a.mimeType === "application/zip" ? `${a.name}.zip` : a.name, | |
| 1508 | - })); | |
| 1509 | - return [...current, ...legacy.filter((l) => !current.some((c) => c.name === l.name))].sort((a, b) => a.name.localeCompare(b.name)); | |
| 1512 | + return this.repo.runtime | |
| 1513 | + .runArtifacts(runId) | |
| 1514 | + .map((a) => ({ | |
| 1515 | + name: a.name, | |
| 1516 | + size: a.size, | |
| 1517 | + createdAt: a.createdAt, | |
| 1518 | + r2Keys: artifactObjectKeys(this.repo.meta.repoId, a), | |
| 1519 | + id: a.id, | |
| 1520 | + contentType: a.mimeType, | |
| 1521 | + fileName: artifactFileName(a), | |
| 1522 | + })) | |
| 1523 | + .sort((a, b) => a.name.localeCompare(b.name)); | |
| 1510 | 1524 | } |
| 1511 | 1525 | |
| 1512 | 1526 | // ---------------------------------------------------------------- sandboxes for agents |
modifiedapps/diffinery/src/repo/collab.ts+7−0View file
| @@ -237,6 +237,7 @@ | ||
| 237 | 237 | // GitHub list filters (REST `issues` and `pulls` query parameters). |
| 238 | 238 | labels?: string[]; |
| 239 | 239 | assignee?: string; |
| 240 | + milestone?: number | "none" | "*"; | |
| 240 | 241 | since?: string; |
| 241 | 242 | head?: string; |
| 242 | 243 | base?: string; |
| @@ -276,6 +277,12 @@ | ||
| 276 | 277 | where.push("EXISTS (SELECT 1 FROM json_each(assignees) WHERE lower(value) = lower(?))"); |
| 277 | 278 | args.push(opts.assignee); |
| 278 | 279 | } |
| 280 | + if (opts.milestone === "none") where.push("number NOT IN (SELECT number FROM issue_milestones)"); | |
| 281 | + else if (opts.milestone === "*") where.push("number IN (SELECT number FROM issue_milestones)"); | |
| 282 | + else if (opts.milestone !== undefined) { | |
| 283 | + where.push("number IN (SELECT number FROM issue_milestones WHERE milestone = ?)"); | |
| 284 | + args.push(opts.milestone); | |
| 285 | + } | |
| 279 | 286 | if (opts.since) { |
| 280 | 287 | where.push("updated_at >= ?"); |
| 281 | 288 | args.push(opts.since); |
modifiedapps/diffinery/src/repo/imports.ts+13−1View file
| @@ -336,7 +336,19 @@ | ||
| 336 | 336 | if (!input.title?.trim()) throw new HttpError(422, "title is required"); |
| 337 | 337 | this.sql.exec("INSERT INTO counters (name, value) VALUES ('milestone', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1"); |
| 338 | 338 | const n = this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'milestone'").one().value; |
| 339 | - this.sql.exec("INSERT INTO milestones (number, title, description, state, due_on, created_at, updated_at, creator) VALUES (?, ?, ?, 'open', ?, ?, ?, ?)", n, input.title.trim(), input.description ?? "", input.dueOn ?? null, t, t, creator); | |
| 339 | + const state = input.state ?? "open"; | |
| 340 | + this.sql.exec( | |
| 341 | + "INSERT INTO milestones (number, title, description, state, due_on, created_at, updated_at, creator, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 342 | + n, | |
| 343 | + input.title.trim(), | |
| 344 | + input.description ?? "", | |
| 345 | + state, | |
| 346 | + input.dueOn ?? null, | |
| 347 | + t, | |
| 348 | + t, | |
| 349 | + creator, | |
| 350 | + state === "closed" ? t : null, | |
| 351 | + ); | |
| 340 | 352 | return this.milestone(n); |
| 341 | 353 | } |
| 342 | 354 | const m = this.milestone(number); |
modifiedapps/diffinery/src/repo/runtime.ts+64−14View file
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | 2 | import { dropStaged, finalKey, runComposeStep, stagingPrefix, stepKeys, type BlobBucket, type ComposeStep } from "../lib/blob-store.js"; |
| 3 | 3 | import { READ_ALL, roleForPermissions, type JobPermissions } from "../lib/job-permissions.js"; |
| 4 | +import { legacyArtifactKeys } from "../lib/storage-keys.js"; | |
| 4 | 5 | import { HttpError, now } from "../lib/util.js"; |
| 5 | 6 | import type { RepoDO } from "./RepoDO.js"; |
| 6 | 7 | import type { Role } from "./types.js"; |
| @@ -12,10 +13,16 @@ | ||
| 12 | 13 | * wire protocols and moves the bytes; this module decides what may be read or |
| 13 | 14 | * written. Every call made with a runtime token names the job, and works only |
| 14 | 15 | * while that job is running. |
| 16 | + * | |
| 17 | + * Artifacts that older runners upload through the runner API (a tarball per | |
| 18 | + * name, stored at its legacy key) are recorded here too, as `legacy` rows, so | |
| 19 | + * they get numeric ids, are listed, counted, expired and deleted like the | |
| 20 | + * others. The toolkit's own listing leaves them out, as GitHub's v4 service | |
| 21 | + * leaves out v3 artifacts. | |
| 15 | 22 | */ |
| 16 | 23 | |
| 17 | 24 | export type CacheEntry = { id: number; key: string; version: string; scope: string; size: number; createdAt: string; lastUsed: string; etag: string | null }; |
| 18 | -export type ArtifactEntry = { id: number; runId: string; runNumber: number; headBranch: string; headSha: string; jobId: string; name: string; size: number; digest: string | null; mimeType: string; createdAt: string; updatedAt: string; expiresAt: string; expired: boolean; etag: string | null }; | |
| 25 | +export type ArtifactEntry = { id: number; runId: string; runNumber: number; headBranch: string; headSha: string; jobId: string; name: string; size: number; digest: string | null; mimeType: string; createdAt: string; updatedAt: string; expiresAt: string; expired: boolean; etag: string | null; legacy: boolean }; | |
| 19 | 26 | |
| 20 | 27 | export type JobScope = { |
| 21 | 28 | runId: string; |
| @@ -25,11 +32,11 @@ | ||
| 25 | 32 | }; |
| 26 | 33 | |
| 27 | 34 | type CacheRow = { id: number; key: string; version: string; scope: string; size: number; status: string; job_id: string | null; created_at: string; last_used: string; staged: number; etag: string | null }; |
| 28 | -type ArtifactRow = { id: number; run_id: string; job_id: string; name: string; size: number; digest: string | null; mime_type: string; status: string; created_at: string; updated_at: string; expires_at: string; staged: number; etag: string | null }; | |
| 35 | +type ArtifactRow = { id: number; run_id: string; job_id: string; name: string; size: number; digest: string | null; mime_type: string; status: string; created_at: string; updated_at: string; expires_at: string; staged: number; etag: string | null; legacy: number }; | |
| 29 | 36 | |
| 30 | 37 | /** Per-repository cache budget, and how long an unused entry is kept (GitHub's limits). */ |
| 31 | 38 | export const CACHE_LIMIT_BYTES = 10 * 1024 ** 3; |
| 32 | -const CACHE_IDLE_MS = 7 * 24 * 3600_000; | |
| 39 | +export const CACHE_IDLE_MS = 7 * 24 * 3600_000; | |
| 33 | 40 | /** Artifact retention: `retention-days` is clamped to 1..90 days, GitHub's default maximum. */ |
| 34 | 41 | export const MAX_RETENTION_DAYS = 90; |
| 35 | 42 | /** How long an expired artifact stays listed (as `expired: true`) after its content is deleted. */ |
| @@ -41,6 +48,16 @@ | ||
| 41 | 48 | |
| 42 | 49 | const toCache = (r: CacheRow): CacheEntry => ({ id: r.id, key: r.key, version: r.version, scope: r.scope, size: r.size, createdAt: r.created_at, lastUsed: r.last_used, etag: r.etag }); |
| 43 | 50 | |
| 51 | +/** Where an artifact's content is: its object, or an older runner's tarball (the current key, then the old layout's). */ | |
| 52 | +export function artifactObjectKeys(repo: string, a: Pick<ArtifactEntry, "id" | "runId" | "name" | "legacy">): string[] { | |
| 53 | + return a.legacy ? legacyArtifactKeys(repo, a.runId, a.name) : [finalKey({ repo, kind: "artifact", id: a.id })]; | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** The file an artifact downloads as: a zip, a single unarchived file, or an older runner's tarball. */ | |
| 57 | +export function artifactFileName(a: Pick<ArtifactEntry, "name" | "mimeType" | "legacy">): string { | |
| 58 | + return a.legacy ? `${a.name}.tar.gz` : a.mimeType === "application/zip" ? `${a.name}.zip` : a.name; | |
| 59 | +} | |
| 60 | + | |
| 44 | 61 | export class Runtime { |
| 45 | 62 | constructor( |
| 46 | 63 | private repo: RepoDO, |
| @@ -166,9 +183,7 @@ | ||
| 166 | 183 | |
| 167 | 184 | /** Bytes of live artifacts (from both upload paths) plus everything staged by uploads in progress. */ |
| 168 | 185 | private artifactBytes(): number { |
| 169 | - const runtime = this.sql.exec<{ n: number | null }>("SELECT sum(CASE WHEN status = 'ready' THEN size ELSE staged END) AS n FROM rt_artifacts WHERE status != 'ready' OR expires_at > ?", now()).one().n ?? 0; | |
| 170 | - const legacy = this.sql.exec<{ n: number | null }>("SELECT sum(size) AS n FROM artifacts").one().n ?? 0; | |
| 171 | - return runtime + legacy; | |
| 186 | + return this.sql.exec<{ n: number | null }>("SELECT sum(CASE WHEN status = 'ready' THEN size ELSE staged END) AS n FROM rt_artifacts WHERE status != 'ready' OR expires_at > ?", now()).one().n ?? 0; | |
| 172 | 187 | } |
| 173 | 188 | |
| 174 | 189 | /** |
| @@ -279,6 +294,12 @@ | ||
| 279 | 294 | if (staged) for (const id of ids.slice(0, 20)) await dropStaged(this.bucket, { repo: this.repoId, kind, id }); |
| 280 | 295 | } |
| 281 | 296 | |
| 297 | + /** Deletes older runners' tarballs (both layouts' keys). */ | |
| 298 | + private async dropLegacyArtifacts(rows: Array<{ runId: string; name: string }>): Promise<void> { | |
| 299 | + const keys = rows.flatMap((r) => legacyArtifactKeys(this.repoId, r.runId, r.name)); | |
| 300 | + for (let i = 0; i < keys.length; i += 1000) await this.bucket.delete(keys.slice(i, i + 1000)); | |
| 301 | + } | |
| 302 | + | |
| 282 | 303 | /** |
| 283 | 304 | * When a job ends, its unfinished uploads go: their rows, so they stop counting |
| 284 | 305 | * against the budgets, and their objects. |
| @@ -362,6 +383,7 @@ | ||
| 362 | 383 | expiresAt: r.expires_at, |
| 363 | 384 | expired: r.status === "expired" || r.expires_at <= now(), |
| 364 | 385 | etag: r.etag, |
| 386 | + legacy: r.legacy === 1, | |
| 365 | 387 | }; |
| 366 | 388 | }; |
| 367 | 389 | |
| @@ -418,6 +440,27 @@ | ||
| 418 | 440 | return this.artifactGet(a.id)!; |
| 419 | 441 | } |
| 420 | 442 | |
| 443 | + /** | |
| 444 | + * Refuses an older runner's upload of `name` when the artifact service already | |
| 445 | + * has an artifact of that name in the run (names are unique in a run). The | |
| 446 | + * runner's own earlier upload of the name is replaced instead. | |
| 447 | + */ | |
| 448 | + legacyArtifactCheck(runId: string, name: string): void { | |
| 449 | + const taken = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM rt_artifacts WHERE run_id = ? AND name = ? AND legacy = 0 AND status = 'ready' AND expires_at > ?", runId, name, now()).one().n; | |
| 450 | + if (taken) throw new HttpError(409, "an artifact with this name already exists on the workflow run"); | |
| 451 | + } | |
| 452 | + | |
| 453 | + /** Records an older runner's upload, already stored at its legacy key; kept for the longest retention. */ | |
| 454 | + legacyArtifactSaved(runId: string, jobId: string, name: string, size: number): ArtifactEntry { | |
| 455 | + this.legacyArtifactCheck(runId, name); | |
| 456 | + // The new tarball was written over the earlier one's, so only the earlier row goes. | |
| 457 | + this.sql.exec("DELETE FROM rt_artifacts WHERE run_id = ? AND name = ? AND legacy = 1", runId, name); | |
| 458 | + const t = now(); | |
| 459 | + const expires = new Date(Date.now() + MAX_RETENTION_DAYS * 86400_000).toISOString(); | |
| 460 | + this.sql.exec("INSERT INTO rt_artifacts (run_id, job_id, name, size, mime_type, status, created_at, updated_at, expires_at, legacy) VALUES (?, ?, ?, ?, 'application/gzip', 'ready', ?, ?, ?, 1)", runId, jobId, name, size, t, t, expires); | |
| 461 | + return this.artifactGet(this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id)!; | |
| 462 | + } | |
| 463 | + | |
| 421 | 464 | /** A finished artifact; with `includeExpired` (the REST API), also one past its retention. */ |
| 422 | 465 | artifactGet(id: number, o: { includeExpired?: boolean } = {}): ArtifactEntry | null { |
| 423 | 466 | const r = o.includeExpired |
| @@ -428,10 +471,11 @@ | ||
| 428 | 471 | |
| 429 | 472 | /** |
| 430 | 473 | * Finished, unexpired artifacts of a run (latest first), optionally by name or id; |
| 431 | - * with `includeExpired` (the REST API), expired ones too. | |
| 474 | + * with `includeExpired` (the REST API), expired ones too; with `legacy: false`, | |
| 475 | + * not those uploaded by older runners. | |
| 432 | 476 | * A numeric run id (github.run_id, the run number) covers all of the run's attempts. |
| 433 | 477 | */ |
| 434 | - runArtifacts(runId: string, filter: { name?: string; id?: number; includeExpired?: boolean } = {}): ArtifactEntry[] { | |
| 478 | + runArtifacts(runId: string, filter: { name?: string; id?: number; includeExpired?: boolean; legacy?: false } = {}): ArtifactEntry[] { | |
| 435 | 479 | const numeric = /^\d+$/.test(runId); |
| 436 | 480 | const where = [numeric ? "run_id IN (SELECT id FROM runs WHERE run_number = ?)" : "run_id = ?", ...(filter.includeExpired ? ["status IN ('ready', 'expired')"] : ["status = 'ready'", "expires_at > ?"])]; |
| 437 | 481 | const args: unknown[] = [numeric ? Number(runId) : runId, ...(filter.includeExpired ? [] : [now()])]; |
| @@ -443,12 +487,13 @@ | ||
| 443 | 487 | where.push("id = ?"); |
| 444 | 488 | args.push(filter.id); |
| 445 | 489 | } |
| 490 | + if (filter.legacy === false) where.push("legacy = 0"); | |
| 446 | 491 | return this.sql.exec<ArtifactRow>(`SELECT * FROM rt_artifacts WHERE ${where.join(" AND ")} ORDER BY id DESC`, ...args).toArray().map(this.toArtifact); |
| 447 | 492 | } |
| 448 | 493 | |
| 449 | - /** The artifacts of a running job's own run (the toolkit's ListArtifacts and GetSignedArtifactURL). */ | |
| 494 | + /** The artifacts the artifact service uploaded to a running job's own run (the toolkit's ListArtifacts and GetSignedArtifactURL). */ | |
| 450 | 495 | jobArtifacts(jobId: string, filter: { name?: string; id?: number } = {}): ArtifactEntry[] { |
| 451 | - return this.runArtifacts(this.job(jobId).runId, filter); | |
| 496 | + return this.runArtifacts(this.job(jobId).runId, { ...filter, legacy: false }); | |
| 452 | 497 | } |
| 453 | 498 | |
| 454 | 499 | /** REST: GET /actions/artifacts, expired artifacts included (as GitHub lists them). */ |
| @@ -470,7 +515,8 @@ | ||
| 470 | 515 | async artifactDelete(id: number): Promise<ArtifactEntry | null> { |
| 471 | 516 | const a = this.artifactGet(id, { includeExpired: true }); |
| 472 | 517 | this.sql.exec("DELETE FROM rt_artifacts WHERE id = ?", id); |
| 473 | - await this.dropObjects("artifact", [id], false); | |
| 518 | + if (a?.legacy) await this.dropLegacyArtifacts([a]); | |
| 519 | + else await this.dropObjects("artifact", [id], false); | |
| 474 | 520 | return a; |
| 475 | 521 | } |
| 476 | 522 | |
| @@ -488,12 +534,16 @@ | ||
| 488 | 534 | */ |
| 489 | 535 | async expireArtifacts(): Promise<void> { |
| 490 | 536 | const t = now(); |
| 491 | - const due = this.sql.exec<{ id: number; status: string }>("SELECT id, status FROM rt_artifacts WHERE expires_at <= ? AND status != 'expired' LIMIT 500", t).toArray(); | |
| 492 | - const ready = due.filter((a) => a.status === "ready").map((a) => a.id); | |
| 537 | + const due = this.sql | |
| 538 | + .exec<{ id: number; status: string; legacy: number; run_id: string; name: string }>("SELECT id, status, legacy, run_id, name FROM rt_artifacts WHERE expires_at <= ? AND status != 'expired' LIMIT 500", t) | |
| 539 | + .toArray(); | |
| 540 | + const ready = due.filter((a) => a.status === "ready" && !a.legacy).map((a) => a.id); | |
| 541 | + const legacy = due.filter((a) => a.status === "ready" && a.legacy).map((a) => ({ id: a.id, runId: a.run_id, name: a.name })); | |
| 493 | 542 | const unfinished = due.filter((a) => a.status !== "ready").map((a) => a.id); |
| 494 | - for (const id of ready) this.sql.exec("UPDATE rt_artifacts SET status = 'expired', staged = 0 WHERE id = ?", id); | |
| 543 | + for (const id of [...ready, ...legacy.map((a) => a.id)]) this.sql.exec("UPDATE rt_artifacts SET status = 'expired', staged = 0 WHERE id = ?", id); | |
| 495 | 544 | for (const id of unfinished) this.sql.exec("DELETE FROM rt_artifacts WHERE id = ?", id); |
| 496 | 545 | if (ready.length) await this.dropObjects("artifact", ready, false); |
| 546 | + if (legacy.length) await this.dropLegacyArtifacts(legacy); | |
| 497 | 547 | if (unfinished.length) await this.dropObjects("artifact", unfinished); |
| 498 | 548 | const forgotten = new Date(Date.now() - EXPIRED_LISTED_DAYS * 86400_000).toISOString(); |
| 499 | 549 | this.sql.exec("DELETE FROM rt_artifacts WHERE status = 'expired' AND expires_at <= ?", forgotten); |
modifiedapps/diffinery/src/repo/schema.ts+12−0View file
| @@ -61,6 +61,7 @@ | ||
| 61 | 61 | CREATE INDEX IF NOT EXISTS jobs_run ON jobs(run_id); |
| 62 | 62 | CREATE TABLE IF NOT EXISTS logs (job_id TEXT NOT NULL, seq INTEGER NOT NULL, t TEXT NOT NULL, step INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (job_id, seq)); |
| 63 | 63 | CREATE TABLE IF NOT EXISTS caches (key TEXT PRIMARY KEY, size INTEGER NOT NULL, created_at TEXT NOT NULL, last_used TEXT NOT NULL); |
| 64 | +-- Where older runners' artifacts were recorded before rt_artifacts; migrateRepoSchema moves them over. | |
| 64 | 65 | CREATE TABLE IF NOT EXISTS artifacts (id TEXT PRIMARY KEY, run_id TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL, created_at TEXT NOT NULL, UNIQUE(run_id, name)); |
| 65 | 66 | CREATE TABLE IF NOT EXISTS merge_queue ( |
| 66 | 67 | number INTEGER PRIMARY KEY, base_ref TEXT NOT NULL, seq INTEGER NOT NULL, head_sha TEXT NOT NULL, method TEXT NOT NULL, |
| @@ -163,6 +164,8 @@ | ||
| 163 | 164 | ["rt_caches", "etag", "TEXT"], |
| 164 | 165 | ["rt_artifacts", "staged", "INTEGER NOT NULL DEFAULT 0"], |
| 165 | 166 | ["rt_artifacts", "etag", "TEXT"], |
| 167 | + // 1 for an artifact an older runner uploaded through the runner API: a tarball at its legacy key (repo/runtime.ts). | |
| 168 | + ["rt_artifacts", "legacy", "INTEGER NOT NULL DEFAULT 0"], | |
| 166 | 169 | // Who created a check run over the REST API (null for Diffinery Actions), and GitHub's numeric id. |
| 167 | 170 | ["checks", "creator_id", "TEXT"], |
| 168 | 171 | ["checks", "creator_login", "TEXT"], |
| @@ -190,4 +193,13 @@ | ||
| 190 | 193 | const has = sql.exec<{ n: number }>(`SELECT count(*) AS n FROM pragma_table_info('${table}') WHERE name = ?`, column).one().n > 0; |
| 191 | 194 | if (!has) sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); |
| 192 | 195 | } |
| 196 | + // Older runners' artifacts were first recorded in `artifacts`, which the REST API didn't list: they | |
| 197 | + // move to rt_artifacts, kept for 90 days from their upload. One shadowed by an artifact service | |
| 198 | + // upload of the same name was never shown and is dropped (its object goes with the bucket's age rule). | |
| 199 | + sql.exec( | |
| 200 | + `INSERT INTO rt_artifacts (run_id, job_id, name, size, mime_type, status, created_at, updated_at, expires_at, legacy) | |
| 201 | + SELECT a.run_id, '', a.name, a.size, 'application/gzip', 'ready', a.created_at, a.created_at, strftime('%Y-%m-%dT%H:%M:%fZ', a.created_at, '+90 days'), 1 FROM artifacts a | |
| 202 | + WHERE NOT EXISTS (SELECT 1 FROM rt_artifacts r WHERE r.run_id = a.run_id AND r.name = a.name)`, | |
| 203 | + ); | |
| 204 | + sql.exec("DELETE FROM artifacts"); | |
| 193 | 205 | } |
modifiedapps/diffinery/src/repo/token-policy.ts+3−2View file
| @@ -48,7 +48,8 @@ | ||
| 48 | 48 | return row ? { ...LEGACY, ...(JSON.parse(row.value) as Partial<WorkflowSettings>) } : { ...LEGACY }; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | - setSettings(patch: Partial<WorkflowSettings>, caller: Caller): WorkflowSettings { | |
| 51 | + /** Changes the settings and audits the change; `via: "api"` marks one made through the REST API. */ | |
| 52 | + setSettings(patch: Partial<WorkflowSettings>, caller: Caller, o: { via?: "api" } = {}): WorkflowSettings { | |
| 52 | 53 | if (caller.role !== "admin") throw new HttpError(403, "admin access required"); |
| 53 | 54 | if (caller.kind !== "user") throw new HttpError(403, "only people can change what workflows are allowed to do"); |
| 54 | 55 | if (patch.defaultPermissions !== undefined && patch.defaultPermissions !== "read" && patch.defaultPermissions !== "write") throw new HttpError(422, "default_workflow_permissions must be read or write"); |
| @@ -56,7 +57,7 @@ | ||
| 56 | 57 | const next = { ...this.settings(), ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) } as WorkflowSettings; |
| 57 | 58 | this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", SETTINGS_KEY, JSON.stringify(next)); |
| 58 | 59 | const m = this.repo.meta; |
| 59 | - this.repo.emit({ type: "audit", action: "repo.actions_workflow_settings", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { ...next }, at: now() }); | |
| 60 | + this.repo.emit({ type: "audit", action: "repo.actions_workflow_settings", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { ...next, ...(o.via ? { via: o.via } : {}) }, at: now() }); | |
| 60 | 61 | return next; |
| 61 | 62 | } |
| 62 | 63 |
modifiedapps/diffinery/src/web/collab.tsx+14−4View file
| @@ -118,17 +118,19 @@ | ||
| 118 | 118 | const label = c.req.query("label") ?? ""; |
| 119 | 119 | const q = (c.req.query("q") ?? "").slice(0, 200); |
| 120 | 120 | const author = c.req.query("author") ?? ""; |
| 121 | + const ms = /^\d+$/.test(c.req.query("milestone") ?? "") ? Number(c.req.query("milestone")) : undefined; | |
| 121 | 122 | const page = pageNum(c); |
| 122 | 123 | const per = 25; |
| 123 | - const filter = { kind, label: label || undefined, q: q || undefined, author: author || undefined }; | |
| 124 | - const [list, other, labels] = await Promise.all([ | |
| 124 | + const filter = { kind, label: label || undefined, q: q || undefined, author: author || undefined, milestone: ms }; | |
| 125 | + const [list, other, labels, milestone] = await Promise.all([ | |
| 125 | 126 | stub.issues({ ...filter, state, limit: per, offset: (page - 1) * per }), |
| 126 | 127 | stub.issues({ ...filter, state: state === "open" ? "closed" : "open", limit: 1 }), |
| 127 | 128 | stub.labels(), |
| 129 | + ms === undefined ? null : stub.milestones("all").then((all) => all.find((m) => m.number === ms) ?? null), | |
| 128 | 130 | ]); |
| 129 | 131 | const colors = new Map(labels.map((l) => [l.name, l.color])); |
| 130 | 132 | const path = kind === "pull" ? "pulls" : "issues"; |
| 131 | - const href = (p: Record<string, string | number | undefined>) => `${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, label: label || undefined, q: q || undefined, author: author || undefined, ...p })}`; | |
| 133 | + const href = (p: Record<string, string | number | undefined>) => `${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, label: label || undefined, q: q || undefined, author: author || undefined, milestone: ms, ...p })}`; | |
| 132 | 134 | const openCount = state === "open" ? list.total : other.total; |
| 133 | 135 | const closedCount = state === "closed" ? list.total : other.total; |
| 134 | 136 | const isAccount = c.get("principal").type === "account"; |
| @@ -141,6 +143,7 @@ | ||
| 141 | 143 | <form method="get" action={`${base}/${path}`} class="row grow" style="max-width:640px"> |
| 142 | 144 | <input type="hidden" name="state" value={state === "closed" ? "closed" : ""} /> |
| 143 | 145 | {label ? <input type="hidden" name="label" value={label} /> : null} |
| 146 | + {ms !== undefined ? <input type="hidden" name="milestone" value={String(ms)} /> : null} | |
| 144 | 147 | <input type="search" name="q" value={q} placeholder={`Search ${kind === "pull" ? "pull requests" : "issues"}… ( / )`} class="grow" style="flex:1" /> |
| 145 | 148 | </form> |
| 146 | 149 | <div class="row"> |
| @@ -177,6 +180,11 @@ | ||
| 177 | 180 | Author: <b>{author}</b> <a href={href({ author: undefined })}>clear</a> |
| 178 | 181 | </span> |
| 179 | 182 | ) : null} |
| 183 | + {ms !== undefined ? ( | |
| 184 | + <span class="small"> | |
| 185 | + Milestone: <b>{milestone?.title ?? `#${ms}`}</b> <a href={href({ milestone: undefined, page: undefined })}>clear</a> | |
| 186 | + </span> | |
| 187 | + ) : null} | |
| 180 | 188 | {labels.length ? ( |
| 181 | 189 | <details class="menu"> |
| 182 | 190 | <summary class="btn sm">Label ▾</summary> |
| @@ -519,7 +527,7 @@ | ||
| 519 | 527 | <h3>Milestone</h3> |
| 520 | 528 | {x.issue.milestone ? ( |
| 521 | 529 | <p class="small m0"> |
| 522 | - <a href={`${base}/issues${qs({ q: `milestone:${x.issue.milestone.number}`, state: "all" })}`}>{x.issue.milestone.title}</a>{" "} | |
| 530 | + <a href={`${base}/${x.issue.kind === "pull" ? "pulls" : "issues"}${qs({ milestone: x.issue.milestone.number })}`}>{x.issue.milestone.title}</a>{" "} | |
| 523 | 531 | {x.issue.milestone.state === "closed" ? <span class="badge">closed</span> : null} |
| 524 | 532 | {x.issue.milestone.dueOn ? <span class="muted"> · due {x.issue.milestone.dueOn.slice(0, 10)}</span> : null} |
| 525 | 533 | </p> |
| @@ -1213,6 +1221,8 @@ | ||
| 1213 | 1221 | |
| 1214 | 1222 | export function registerCollab(web: WebApp): void { |
| 1215 | 1223 | web.get("/:owner/:repo/issues", (c) => listPage(c, "issue")); |
| 1224 | + // GitHub's milestone page (the html_url of a milestone): its issues and pull requests, as a filtered issue list. | |
| 1225 | + web.get("/:owner/:repo/milestone/:n{[0-9]+}", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/issues?milestone=${c.req.param("n")}`)), | |
| 1216 | 1226 | web.get("/:owner/:repo/pulls", (c) => listPage(c, "pull")); |
| 1217 | 1227 | |
| 1218 | 1228 | const NewIssue: FC<{ c: AppContext; l: Loaded; labels: LabelDef[]; v: { title: string; body: string; labels: string[] }; error?: string }> = ({ c, l, labels, v, error }) => ( |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+62−0View file
| @@ -1176,6 +1176,10 @@ | ||
| 1176 | 1176 | assert.deepEqual(await restored(), { builtin: "refs/heads/main", real: "refs/heads/main" }); |
| 1177 | 1177 | const caches = await ok("GET", `/repos/${owner}/scopes/actions/caches?per_page=100`); |
| 1178 | 1178 | assert.deepEqual([...new Set(caches.actions_caches.map((c) => c.ref))].sort(), ["refs/heads/a", "refs/heads/b", "refs/heads/main", "refs/pull/1/merge", "refs/pull/2/merge"]); |
| 1179 | + // A page at a time, with GitHub's Link header. | |
| 1180 | + const page = await fetch(`${base}/api/v3/repos/${owner}/scopes/actions/caches?per_page=2`, { headers: { authorization: `Bearer ${token}` } }); | |
| 1181 | + assert.equal((await page.json()).actions_caches.length, 2); | |
| 1182 | + assert.match(page.headers.get("link") ?? "", /[?&]page=2>; rel="next".*rel="last"/); | |
| 1179 | 1183 | |
| 1180 | 1184 | // retention-days (the built-in and the real upload-artifact), capped at 90 days. |
| 1181 | 1185 | const days = (iso) => Math.round((Date.parse(iso) - Date.now()) / 86_400_000); |
| @@ -1287,6 +1291,64 @@ | ||
| 1287 | 1291 | assert.equal(await get(real), 401); |
| 1288 | 1292 | }); |
| 1289 | 1293 | |
| 1294 | +test("an older runner's artifacts are listed, downloaded and deleted through the REST API", async () => { | |
| 1295 | + const owner = (await ok("GET", "/user")).login; | |
| 1296 | + await ok("POST", "/user/repos", { name: "legacy-rest" }); | |
| 1297 | + const src = join(work, "legacy-rest"); | |
| 1298 | + mkdirSync(join(src, ".github/workflows"), { recursive: true }); | |
| 1299 | + writeFileSync(join(src, ".github/workflows/w.yml"), "on: push\njobs:\n j:\n runs-on: [self-hosted, linux]\n steps: [{ run: echo }]\n"); | |
| 1300 | + git(src, ["init", "-q", "-b", "main"]); | |
| 1301 | + git(src, ["add", "-A"]); | |
| 1302 | + git(src, ["commit", "-q", "-m", "legacy"]); | |
| 1303 | + git(src, ["remote", "add", "origin", `${base}/${owner}/legacy-rest.git`]); | |
| 1304 | + git(src, ["push", "-q", "origin", "main"]); | |
| 1305 | + const claim = await until("a job to claim", async () => { | |
| 1306 | + const r = await api("POST", `/repos/${owner}/legacy-rest/actions/runners/claim`, { name: "old-runner", labels: ["linux"] }); | |
| 1307 | + return r.status === 200 ? r.json : null; | |
| 1308 | + }); | |
| 1309 | + const h = { authorization: `Bearer ${claim.token}` }; | |
| 1310 | + const at = `${base}/runner/jobs/${claim.job_id}`; | |
| 1311 | + assert.equal((await fetch(at, { headers: h })).status, 200); | |
| 1312 | + for (const name of ["one", "two", "three"]) { | |
| 1313 | + assert.equal((await fetch(`${at}/artifacts/${name}`, { method: "PUT", headers: { ...h, "content-length": String(name.length) }, body: name })).status, 200); | |
| 1314 | + } | |
| 1315 | + const rest = (path, init = {}) => fetch(`${base}/api/v3/repos/${owner}/legacy-rest${path}`, { ...init, headers: { authorization: `Bearer ${token}` } }); | |
| 1316 | + | |
| 1317 | + // Listed with numeric ids, across the repository and for the run, a page at a time. | |
| 1318 | + let r = await rest("/actions/artifacts?per_page=2"); | |
| 1319 | + let body = await r.json(); | |
| 1320 | + assert.equal(body.total_count, 3, JSON.stringify(body)); | |
| 1321 | + assert.equal(body.artifacts.length, 2); | |
| 1322 | + assert.ok(body.artifacts.every((a) => Number.isSafeInteger(a.id) && a.expired === false), JSON.stringify(body)); | |
| 1323 | + assert.match(r.headers.get("link") ?? "", /[?&]page=2>; rel="next"/); | |
| 1324 | + const runId = body.artifacts[0].workflow_run.id; | |
| 1325 | + r = await rest(`/actions/runs/${runId}/artifacts?per_page=2&page=2`); | |
| 1326 | + body = await r.json(); | |
| 1327 | + assert.equal(body.total_count, 3); | |
| 1328 | + assert.equal(body.artifacts.length, 1); | |
| 1329 | + assert.match(r.headers.get("link") ?? "", /rel="prev"/); | |
| 1330 | + assert.doesNotMatch(r.headers.get("link") ?? "", /rel="next"/); | |
| 1331 | + const two = (await (await rest(`/actions/runs/${runId}/artifacts?name=two`)).json()).artifacts[0]; | |
| 1332 | + assert.equal((await (await rest(`/actions/artifacts/${two.id}`)).json()).name, "two"); | |
| 1333 | + | |
| 1334 | + // The download is the tarball the runner uploaded. | |
| 1335 | + const zip = await rest(`/actions/artifacts/${two.id}/zip`, { redirect: "manual" }); | |
| 1336 | + assert.equal(zip.status, 302); | |
| 1337 | + const blob = await fetch(zip.headers.get("location")); | |
| 1338 | + assert.equal(blob.status, 200); | |
| 1339 | + assert.equal(blob.headers.get("content-type"), "application/gzip"); | |
| 1340 | + assert.match(blob.headers.get("content-disposition") ?? "", /two\.tar\.gz/); | |
| 1341 | + assert.equal(await blob.text(), "two"); | |
| 1342 | + | |
| 1343 | + // Deleting it removes it for the runner too. | |
| 1344 | + assert.equal((await rest(`/actions/artifacts/${two.id}`, { method: "DELETE" })).status, 204); | |
| 1345 | + assert.equal((await rest(`/actions/artifacts/${two.id}`)).status, 404); | |
| 1346 | + assert.equal((await fetch(`${at}/artifacts/two`, { headers: h })).status, 404); | |
| 1347 | + assert.equal(await (await fetch(`${at}/artifacts/one`, { headers: h })).text(), "one"); | |
| 1348 | + assert.equal((await (await rest("/actions/artifacts")).json()).total_count, 2); | |
| 1349 | + assert.equal((await fetch(`${at}/complete`, { method: "POST", headers: { ...h, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) })).status, 200); | |
| 1350 | +}); | |
| 1351 | + | |
| 1290 | 1352 | const hasDocker = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === 0; |
| 1291 | 1353 | |
| 1292 | 1354 | test("containers: a job container and service containers on a self-hosted runner", { skip: !hasDocker && "Docker is not available" }, async () => { |
modifiedapps/diffinery/test/e2e/security.test.mjs+69−2View file
| @@ -550,9 +550,10 @@ | ||
| 550 | 550 | const fromAdmin = await ok("POST", `${R}/issues`, { title: "from the owner", labels: ["bug"] }); |
| 551 | 551 | assert.equal(fromAdmin.labels[0].name, "bug"); |
| 552 | 552 | // Editing their own issue doesn't let them in either. |
| 553 | - const edited = await ok("PATCH", `${R}/issues/${fromReader.number}`, { title: "edited by its author", labels: ["bug"], assignees: [OWNER] }, T.rita); | |
| 553 | + const milestone = await status(201, "POST", `${R}/milestones`, { title: "v1" }); | |
| 554 | + const edited = await ok("PATCH", `${R}/issues/${fromReader.number}`, { title: "edited by its author", labels: ["bug"], assignees: [OWNER], milestone: milestone.number }, T.rita); | |
| 554 | 555 | assert.equal(edited.title, "edited by its author"); |
| 555 | - assert.deepEqual([edited.labels.length, edited.assignees.length], [0, 0]); | |
| 556 | + assert.deepEqual([edited.labels.length, edited.assignees.length, edited.milestone], [0, 0, null]); | |
| 556 | 557 | |
| 557 | 558 | // ------------------------------------------------------------ settings changed through the API are on the record |
| 558 | 559 | await status(204, "PUT", `${R}/collaborators/rita`, { permission: "push" }); |
| @@ -1215,3 +1216,69 @@ | ||
| 1215 | 1216 | // Job and sandbox tokens can't start sandboxes, whose tokens would outlive them. |
| 1216 | 1217 | await status(403, "POST", `/repos/${OWNER}/minting/diffinery/sandboxes`, {}, job); |
| 1217 | 1218 | }); |
| 1219 | + | |
| 1220 | +test("security: repository variable endpoints see only the repository's own, and organization variable deletes are checked", async () => { | |
| 1221 | + await status(201, "POST", "/orgs/guild/actions/variables", { name: "REGION", value: "org-eu" }); | |
| 1222 | + await ok("POST", "/orgs/guild/repos", { name: "tools", private: true }); | |
| 1223 | + const R = "/repos/guild/tools"; | |
| 1224 | + // The organization's variable isn't the repository's to read or change. | |
| 1225 | + await status(404, "GET", `${R}/actions/variables/REGION`); | |
| 1226 | + await status(404, "PATCH", `${R}/actions/variables/REGION`, { value: "changed" }); | |
| 1227 | + assert.deepEqual((await ok("GET", `${R}/actions/variables`)).variables, []); | |
| 1228 | + // A repository variable of the same name can be created; it overrides the organization's in workflows. | |
| 1229 | + await status(201, "POST", `${R}/actions/variables`, { name: "region", value: "repo-us" }); | |
| 1230 | + assert.equal((await ok("GET", `${R}/actions/variables/REGION`)).value, "repo-us"); | |
| 1231 | + assert.equal((await ok("GET", "/orgs/guild/actions/variables/REGION")).value, "org-eu"); | |
| 1232 | + // Deleting it ignores letter case, like every other variable endpoint. | |
| 1233 | + await status(204, "DELETE", `${R}/actions/variables/region`); | |
| 1234 | + await status(404, "GET", `${R}/actions/variables/REGION`); | |
| 1235 | + | |
| 1236 | + // Deleting an organization variable that doesn't exist is a 404 and leaves no audit entry; API changes say so. | |
| 1237 | + await status(404, "DELETE", "/orgs/guild/actions/variables/NEVER_SET"); | |
| 1238 | + await status(204, "DELETE", "/orgs/guild/actions/variables/REGION"); | |
| 1239 | + await status(404, "GET", "/orgs/guild/actions/variables/REGION"); | |
| 1240 | + const deleted = await until("the variable's audit entry", async () => auditLog("org.variable_delete").find((e) => JSON.parse(e.detail).name === "REGION")); | |
| 1241 | + assert.equal(JSON.parse(deleted.detail).via, "api"); | |
| 1242 | + assert.ok(!auditLog("org.variable_delete").some((e) => JSON.parse(e.detail).name === "NEVER_SET")); | |
| 1243 | + assert.equal(JSON.parse(auditLog("org.variable_set").find((e) => JSON.parse(e.detail).name === "REGION").detail).via, "api"); | |
| 1244 | +}); | |
| 1245 | + | |
| 1246 | +test("security: milestones are checked before an issue is saved, and lists filter by them", async () => { | |
| 1247 | + const R = "/repos/guild/tools"; | |
| 1248 | + // A milestone can be created closed. | |
| 1249 | + const done = await status(201, "POST", `${R}/milestones`, { title: "Already done", state: "closed" }); | |
| 1250 | + assert.equal(done.state, "closed"); | |
| 1251 | + assert.ok(done.closed_at); | |
| 1252 | + const v1 = await status(201, "POST", `${R}/milestones`, { title: "v1" }); | |
| 1253 | + assert.equal(v1.state, "open"); | |
| 1254 | + const planned = await status(201, "POST", `${R}/issues`, { title: "Planned" }); | |
| 1255 | + const unplanned = await status(201, "POST", `${R}/issues`, { title: "Unplanned" }); | |
| 1256 | + | |
| 1257 | + // A milestone that doesn't exist is refused before the rest of the edit is saved. | |
| 1258 | + await status(422, "PATCH", `${R}/issues/${planned.number}`, { title: "Renamed", milestone: 999 }); | |
| 1259 | + assert.equal((await ok("GET", `${R}/issues/${planned.number}`)).title, "Planned"); | |
| 1260 | + await status(422, "PATCH", `${R}/issues/${planned.number}`, { milestone: "v1" }); | |
| 1261 | + // The response carries the new milestone. | |
| 1262 | + const set = await ok("PATCH", `${R}/issues/${planned.number}`, { title: "Planned for v1", milestone: v1.number }); | |
| 1263 | + assert.deepEqual([set.title, set.milestone?.number], ["Planned for v1", v1.number]); | |
| 1264 | + | |
| 1265 | + const numbers = async (q) => (await ok("GET", `${R}/issues?${q}`)).map((i) => i.number); | |
| 1266 | + assert.deepEqual(await numbers(`milestone=${v1.number}`), [planned.number]); | |
| 1267 | + assert.deepEqual(await numbers("milestone=*"), [planned.number]); | |
| 1268 | + assert.deepEqual(await numbers("milestone=none"), [unplanned.number]); | |
| 1269 | + await status(422, "GET", `${R}/issues?milestone=v1`); | |
| 1270 | + | |
| 1271 | + // The milestone's link opens the issue list filtered to it. | |
| 1272 | + const cookie = `__Host-dfy_session=${S.olive}`; | |
| 1273 | + const page = await fetch(`${base}/guild/tools/milestone/${v1.number}`, { headers: { cookie }, redirect: "manual" }); | |
| 1274 | + assert.equal(page.status, 302); | |
| 1275 | + const list = await fetch(new URL(page.headers.get("location"), base), { headers: { cookie } }); | |
| 1276 | + const html = await list.text(); | |
| 1277 | + assert.equal(list.status, 200); | |
| 1278 | + assert.match(html, /Planned for v1/); | |
| 1279 | + assert.doesNotMatch(html, /Unplanned/); | |
| 1280 | + assert.match(html, /Milestone: <b>v1<\/b>/); | |
| 1281 | + | |
| 1282 | + // Removing it. | |
| 1283 | + assert.equal((await ok("PATCH", `${R}/issues/${planned.number}`, { milestone: null })).milestone, null); | |
| 1284 | +}); |
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+123−5View file
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import { BodyTooLarge, STEP_OPS, batchParts, blockKey, chunkKey, compose, finalKey, listStaged, parseBlockList, parseRange, planCompose, putStream, runComposeStep, stagingPrefix, type BlobBucket, type ComposeLimits, type ComposeStep } from "../../src/lib/blob-store.js"; |
| 3 | 3 | import { DatabaseSync } from "node:sqlite"; |
| 4 | -import { Runtime } from "../../src/repo/runtime.js"; | |
| 4 | +import { Actions } from "../../src/repo/actions.js"; | |
| 5 | +import { DEFAULT_ARTIFACT_QUOTA, Runtime, artifactFileName, artifactObjectKeys } from "../../src/repo/runtime.js"; | |
| 5 | 6 | import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; |
| 6 | 7 | import type { RepoDO } from "../../src/repo/RepoDO.js"; |
| 7 | 8 | import type { Env } from "../../src/env.js"; |
| @@ -9,7 +10,8 @@ | ||
| 9 | 10 | import { signBlobUrl, signRuntimeToken, verifyBlobUrl, verifyRuntimeToken } from "../../src/lib/runtime-auth.js"; |
| 10 | 11 | import { b64, fromB64url } from "../../src/lib/util.js"; |
| 11 | 12 | import { completePayload, issuePayload, releasePayload } from "../../src/repo/event-payloads.js"; |
| 12 | -import type { RepoMeta } from "../../src/repo/types.js"; | |
| 13 | +import { TokenPolicy } from "../../src/repo/token-policy.js"; | |
| 14 | +import type { Caller, RepoMeta } from "../../src/repo/types.js"; | |
| 13 | 15 | import { numericId } from "../../src/http/gh.js"; |
| 14 | 16 | |
| 15 | 17 | const MASTER = b64(new Uint8Array(32).fill(7)); |
| @@ -397,8 +399,13 @@ | ||
| 397 | 399 | sql.exec(REPO_SCHEMA); |
| 398 | 400 | migrateRepoSchema(sql as unknown as SqlStorage); |
| 399 | 401 | const m = memoryBucket(); |
| 400 | - const repo = { sql, meta: { repoId: "r", defaultBranch: "main" } }; | |
| 401 | - const rt = new Runtime(repo as unknown as RepoDO, { BUCKET: m.bucket } as unknown as Env); | |
| 402 | + const repo = { sql, meta: { repoId: "r", defaultBranch: "main" } } as unknown as RepoDO & { runtime: Runtime }; | |
| 403 | + const env = { BUCKET: m.bucket } as unknown as Env; | |
| 404 | + const rt = new Runtime(repo, env); | |
| 405 | + repo.runtime = rt; | |
| 406 | + // The runner API's side of older runners' caches and artifacts, with every job token taken as valid. | |
| 407 | + const actions = new Actions(repo, env); | |
| 408 | + Object.defineProperty(actions, "authJob", { value: async (id: string) => sql.exec("SELECT * FROM jobs WHERE id = ?", id).one() }); | |
| 402 | 409 | let n = 0; |
| 403 | 410 | /** A running job of a run on `ref`; `payload` is the run's event payload. */ |
| 404 | 411 | const job = (ref: string, payload: Record<string, unknown> = {}) => { |
| @@ -424,7 +431,7 @@ | ||
| 424 | 431 | await rt.cacheCommit(jobId, r!.id, 10, "etag"); |
| 425 | 432 | }; |
| 426 | 433 | const restore = (jobId: string, ...keys: string[]) => rt.cacheLookup(jobId, keys, version); |
| 427 | - return { sql, rt, m, job, save, restore }; | |
| 434 | + return { sql, rt, actions, m, job, save, restore }; | |
| 428 | 435 | } |
| 429 | 436 | |
| 430 | 437 | describe("cache scopes (as on GitHub)", () => { |
| @@ -525,3 +532,114 @@ | ||
| 525 | 532 | expect(f.rt.artifactGet(keep.id, { includeExpired: true })).toBeNull(); |
| 526 | 533 | }); |
| 527 | 534 | }); |
| 535 | + | |
| 536 | +describe("artifacts from older runners", () => { | |
| 537 | + const DAY = 86400_000; | |
| 538 | + /** What the runner API does with an older runner's upload: check the name, store the tarball, record it. */ | |
| 539 | + async function legacyUpload(f: ReturnType<typeof runtimeFixture>, jobId: string, name: string, size: number) { | |
| 540 | + const { r2Keys } = await f.actions.artifactUploadKeys(jobId, "djt_tok", name); | |
| 541 | + await f.m.bucket.put(r2Keys[0]!, new Uint8Array(size)); | |
| 542 | + await f.actions.artifactSaved(jobId, "djt_tok", name, size); | |
| 543 | + return r2Keys; | |
| 544 | + } | |
| 545 | + | |
| 546 | + it("are listed with numeric ids, and can be read and deleted like the others", async () => { | |
| 547 | + const f = runtimeFixture(); | |
| 548 | + const j = f.job("refs/heads/main"); | |
| 549 | + const keys = await legacyUpload(f, j, "logs", 40); | |
| 550 | + expect(keys).toEqual(legacyArtifactKeys("r", "run1", "logs")); | |
| 551 | + const listed = f.rt.repoArtifacts({}); | |
| 552 | + expect(listed.total).toBe(1); | |
| 553 | + const a = listed.artifacts[0]!; | |
| 554 | + expect(a).toMatchObject({ name: "logs", size: 40, expired: false, runId: "run1", runNumber: 1, mimeType: "application/gzip" }); | |
| 555 | + expect(Number.isSafeInteger(a.id) && a.id > 0).toBe(true); | |
| 556 | + expect(f.rt.runArtifacts("run1", { includeExpired: true }).map((x) => x.id)).toEqual([a.id]); | |
| 557 | + expect(f.rt.runArtifacts("1", { includeExpired: true }).map((x) => x.id)).toEqual([a.id]); | |
| 558 | + expect(f.rt.artifactGet(a.id, { includeExpired: true })?.name).toBe("logs"); | |
| 559 | + // Its content stays where the runner put it, and downloads as a tarball. | |
| 560 | + expect(artifactObjectKeys("r", a)).toEqual(keys); | |
| 561 | + expect(artifactFileName(a)).toBe("logs.tar.gz"); | |
| 562 | + expect(f.actions.artifacts("run1")).toMatchObject([{ id: a.id, name: "logs", r2Keys: keys, fileName: "logs.tar.gz", contentType: "application/gzip" }]); | |
| 563 | + // It counts against the quota, and is kept 90 days (upload-artifact's default). | |
| 564 | + expect(await f.rt.artifactRoom()).toBe(DEFAULT_ARTIFACT_QUOTA - 40); | |
| 565 | + expect(Math.round((Date.parse(a.expiresAt) - Date.now()) / DAY)).toBe(90); | |
| 566 | + // Uploading the name again replaces it. | |
| 567 | + await legacyUpload(f, j, "logs", 50); | |
| 568 | + const again = f.rt.repoArtifacts({}).artifacts; | |
| 569 | + expect(again.map((x) => [x.name, x.size])).toEqual([["logs", 50]]); | |
| 570 | + // Deleting it deletes its content. | |
| 571 | + expect((await f.rt.artifactDelete(again[0]!.id))?.name).toBe("logs"); | |
| 572 | + expect(f.m.objects.has(keys[0]!)).toBe(false); | |
| 573 | + expect(f.rt.repoArtifacts({}).total).toBe(0); | |
| 574 | + expect(await f.rt.artifactRoom()).toBe(DEFAULT_ARTIFACT_QUOTA); | |
| 575 | + }); | |
| 576 | + | |
| 577 | + it("expire like the others", async () => { | |
| 578 | + const f = runtimeFixture(); | |
| 579 | + const j = f.job("refs/heads/main"); | |
| 580 | + const [key] = await legacyUpload(f, j, "old", 10); | |
| 581 | + const a = f.rt.repoArtifacts({}).artifacts[0]!; | |
| 582 | + f.sql.exec("UPDATE rt_artifacts SET expires_at = ? WHERE id = ?", new Date(Date.now() - 1000).toISOString(), a.id); | |
| 583 | + await f.rt.expireArtifacts(); | |
| 584 | + expect(f.m.objects.has(key!)).toBe(false); | |
| 585 | + expect(f.rt.artifactGet(a.id, { includeExpired: true })?.expired).toBe(true); | |
| 586 | + expect(await f.rt.artifactRoom()).toBe(DEFAULT_ARTIFACT_QUOTA); | |
| 587 | + }); | |
| 588 | + | |
| 589 | + it("share names with the artifact service's uploads, which don't list them", async () => { | |
| 590 | + const f = runtimeFixture(); | |
| 591 | + const j = f.job("refs/heads/main"); | |
| 592 | + await legacyUpload(f, j, "tarball", 10); | |
| 593 | + // The toolkit talks to GitHub's v4 service, which doesn't list v3 artifacts either. | |
| 594 | + expect(f.rt.jobArtifacts(j)).toEqual([]); | |
| 595 | + await expect(f.rt.artifactCreate(j, "tarball", undefined, undefined)).rejects.toThrow(/already exists/); | |
| 596 | + const { id } = await f.rt.artifactCreate(j, "zip", undefined, undefined); | |
| 597 | + await f.m.bucket.put(finalKey({ repo: "r", kind: "artifact", id }), new Uint8Array(5)); | |
| 598 | + await f.rt.artifactFinalize(j, "zip", 5, undefined, "etag"); | |
| 599 | + await expect(f.actions.artifactUploadKeys(j, "djt_tok", "zip")).rejects.toThrow(/already exists/); | |
| 600 | + }); | |
| 601 | + | |
| 602 | + it("recorded before they were listed are moved over, keeping their age", () => { | |
| 603 | + const f = runtimeFixture(); | |
| 604 | + f.job("refs/heads/main"); | |
| 605 | + const created = new Date(Date.now() - 10 * DAY).toISOString(); | |
| 606 | + f.sql.exec("INSERT INTO artifacts (id, run_id, name, size, created_at) VALUES ('x', 'run1', 'old', 7, ?)", created); | |
| 607 | + migrateRepoSchema(f.sql as unknown as SqlStorage); | |
| 608 | + const [a] = f.rt.repoArtifacts({}).artifacts; | |
| 609 | + expect(a).toMatchObject({ name: "old", size: 7, createdAt: created, legacy: true, expired: false }); | |
| 610 | + expect(Math.round((Date.parse(a!.expiresAt) - Date.now()) / DAY)).toBe(80); | |
| 611 | + expect(f.sql.exec("SELECT count(*) AS n FROM artifacts").one()).toEqual({ n: 0 }); | |
| 612 | + }); | |
| 613 | +}); | |
| 614 | + | |
| 615 | +describe("workflow permission settings", () => { | |
| 616 | + it("audit each change, marking those made through the API", () => { | |
| 617 | + const sql = sqlStorage(); | |
| 618 | + sql.exec(REPO_SCHEMA); | |
| 619 | + const events: Array<{ action: string; detail: Record<string, unknown> }> = []; | |
| 620 | + const repo = { sql, meta: { owner: "acme", name: "app" }, emit: (e: { action: string; detail: Record<string, unknown> }) => events.push(e) }; | |
| 621 | + const policy = new TokenPolicy(repo as unknown as RepoDO); | |
| 622 | + const admin = { id: "u1", login: "ada", kind: "user", role: "admin" } as Caller; | |
| 623 | + policy.setSettings({ defaultPermissions: "read" }, admin, { via: "api" }); | |
| 624 | + // The web settings page calls it without `via`. | |
| 625 | + policy.setSettings({ canApprovePullRequests: true }, admin); | |
| 626 | + expect(events.map((e) => [e.action, e.detail])).toEqual([ | |
| 627 | + ["repo.actions_workflow_settings", { defaultPermissions: "read", canApprovePullRequests: false, via: "api" }], | |
| 628 | + ["repo.actions_workflow_settings", { defaultPermissions: "read", canApprovePullRequests: true }], | |
| 629 | + ]); | |
| 630 | + }); | |
| 631 | +}); | |
| 632 | + | |
| 633 | +describe("an older runner's cache", () => { | |
| 634 | + const DAY = 86400_000; | |
| 635 | + it("drops entries unused for a week, with their objects", async () => { | |
| 636 | + const f = runtimeFixture(); | |
| 637 | + const j = f.job("refs/heads/main"); | |
| 638 | + expect(await f.actions.cacheSaved(j, "djt_tok", "old", 10)).toEqual([]); | |
| 639 | + expect(await f.actions.cacheSaved(j, "djt_tok", "recent", 10)).toEqual([]); | |
| 640 | + f.sql.exec("UPDATE caches SET last_used = ? WHERE key = ?", new Date(Date.now() - 8 * DAY).toISOString(), "refs/heads/main\nold"); | |
| 641 | + f.sql.exec("UPDATE caches SET last_used = ? WHERE key = ?", new Date(Date.now() - 6 * DAY).toISOString(), "refs/heads/main\nrecent"); | |
| 642 | + expect(await f.actions.cacheSaved(j, "djt_tok", "new", 10)).toEqual(legacyCacheKeys("r", "refs/heads/main\nold")); | |
| 643 | + expect(f.sql.exec("SELECT key FROM caches ORDER BY key").toArray()).toEqual([{ key: "refs/heads/main\nnew" }, { key: "refs/heads/main\nrecent" }]); | |
| 644 | + }); | |
| 645 | +}); |
addedapps/diffinery/test/unit/org-variables.test.ts+44−0View file
| @@ -1,0 +1,44 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { Directory } from "../../src/lib/directory.js"; | |
| 3 | +import type { Env } from "../../src/env.js"; | |
| 4 | + | |
| 5 | +/** A D1 double that answers the two reads syncOrgVariables makes and accepts the batch write. */ | |
| 6 | +function fakeEnv(repoIds: string[], failures: Record<string, number>) { | |
| 7 | + const copies: Record<string, Array<Record<string, string>>> = {}; | |
| 8 | + const stmt = (sql: string) => ({ | |
| 9 | + bind: () => stmt(sql), | |
| 10 | + all: async () => ({ results: sql.includes("FROM org_variables") ? [{ name: "REGION", value: "eu", updated_at: "t" }] : repoIds.map((id) => ({ id })) }), | |
| 11 | + run: async () => ({ meta: { changes: 1 } }), | |
| 12 | + }); | |
| 13 | + const env = { | |
| 14 | + DB: { prepare: stmt, batch: async () => [] }, | |
| 15 | + REPO: { | |
| 16 | + idFromName: (id: string) => id, | |
| 17 | + get: (id: string) => ({ | |
| 18 | + setOrgVariables: async (vars: Record<string, string>) => { | |
| 19 | + if ((failures[id] ?? 0) > 0) { | |
| 20 | + failures[id]!--; | |
| 21 | + throw new Error(`${id} is unavailable`); | |
| 22 | + } | |
| 23 | + (copies[id] ??= []).push(vars); | |
| 24 | + }, | |
| 25 | + }), | |
| 26 | + }, | |
| 27 | + } as unknown as Env; | |
| 28 | + return { env, copies }; | |
| 29 | +} | |
| 30 | + | |
| 31 | +describe("organization variables", () => { | |
| 32 | + it("reach every repository even when one fails, retrying the failures once", async () => { | |
| 33 | + const { env, copies } = fakeEnv(["a", "b", "c"], { a: 1 }); | |
| 34 | + await new Directory(env).setOrgVariables("org", { REGION: "eu" }); | |
| 35 | + expect(Object.keys(copies).sort()).toEqual(["a", "b", "c"]); | |
| 36 | + expect(copies.c).toEqual([{ REGION: "eu" }]); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it("says so when a repository still couldn't be updated, after updating the rest", async () => { | |
| 40 | + const { env, copies } = fakeEnv(["a", "b", "c"], { b: 5 }); | |
| 41 | + await expect(new Directory(env).setOrgVariables("org", { REGION: "eu" })).rejects.toThrow(/1 repository wasn't updated; repeat the request/); | |
| 42 | + expect(Object.keys(copies).sort()).toEqual(["a", "c"]); | |
| 43 | + }); | |
| 44 | +}); |