Diffinery

codician/diffinerypublic

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

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)
modifiedapps/diffinery/docs/actions/caching-and-artifacts.md+1−1View file
@@ -461,7 +461,7 @@
461461 | `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. |
462462 | `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`. |
463463
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:
465465
466466 ```json
467467 {
modifiedapps/diffinery/docs/actions/secrets-and-variables.md+6−11View file
@@ -134,7 +134,7 @@
134134 -d '{"name": "DEPLOY_REGION", "value": "eu-west-1"}'
135135 ```
136136
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.
138138
139139 ## Organization variables
140140
@@ -159,7 +159,7 @@
159159 | `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. |
160160 | `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. |
161161 | `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. |
163163
164164 ```sh
165165 # Create or replace
@@ -188,13 +188,8 @@
188188
189189 ### Organization and repository variables with the same name
190190
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.
192192
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-
198193 ## Environment secrets and variables
199194
200195 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 @@
284279 2. Repository variables.
285280 3. Organization variables (repositories owned by an organization only).
286281
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.
288283
289284 ### Which runs receive secrets
290285
@@ -392,7 +387,7 @@
392387 - 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.
393388 - 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.
394389 - 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).
396391
397392 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).
398393
@@ -410,7 +405,7 @@
410405 | Create, update, rename or delete an environment variable | `environment.variable.set` (a rename adds `renamed_from`), `environment.variable.delete` |
411406 | Change an environment or its branch policies | `environment.update`, `environment.delete`, `environment.branch_policy.create`, `environment.branch_policy.delete` |
412407
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).
414409
415410 ## Limits
416411
modifiedapps/diffinery/docs/collaboration/issues.md+9−8View file
@@ -225,7 +225,7 @@
225225 | `number` (also `id`) | The milestone's number in the repository |
226226 | `title` | 1 to 255 characters; required when you create a milestone |
227227 | `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"`. |
229229 | `due_on` | An ISO 8601 timestamp, or `null` for no due date. It's stored in UTC. |
230230 | `creator` | `{"login": "..."}` of the account that created it |
231231 | `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 @@
237237 | --- | --- | --- |
238238 | `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. |
239239 | `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 |
241241 | `PATCH /repos/{owner}/{repo}/milestones/{number}` | triage | Changes any of `title`, `description`, `state` and `due_on` (`null` removes the due date); returns the milestone |
242242
243243 ```sh
@@ -260,8 +260,8 @@
260260
261261 ### Set the milestone of an issue or pull request
262262
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`.
265265
266266 ```sh
267267 curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/12 \
@@ -271,13 +271,13 @@
271271
272272 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`:
273273
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.
276276 - 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.
277277
278278 A workflow's `GITHUB_TOKEN` needs `issues: write` or `pull-requests: write` to create, edit or set milestones.
279279
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.
281281
282282 ## Comments
283283
@@ -474,6 +474,7 @@
474474 - **Search:** the search box (press `/` to focus it) matches text anywhere in the title or the description, up to 200 characters.
475475 - **Label:** pick one from the **Label ▾** menu, or click a label on any issue.
476476 - **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.
477478 - Active filters appear at the top of the list with a **clear** link. Filters combine.
478479
479480 Filters are query parameters, so you can bookmark or share them:
@@ -570,7 +571,7 @@
570571 - An issue's `id` is its number; issues are addressed by number only.
571572 - `/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.
572573 - 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).
574575 - Deleting a label in the web UI keeps its name on issues; deleting it through the API removes it from them.
575576 - Locking, reactions, events and comment deletion are available through the REST API only, not in the web UI.
576577 - `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 @@
7474 | Release | `latest`, whether it is the repository's latest release |
7575 | Pull request file | `binary` |
7676
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`.
7878
7979 ### IDs
8080
modifiedapps/diffinery/docs/security/audit-log.md+1−1View file
@@ -77,7 +77,7 @@
7777 | `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) |
7878 | `org.delete` | An organization is deleted (web or `DELETE /api/v3/orgs/{org}`) | the organization's login | none |
7979 | `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` |
8181 | `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` |
8282 | `org.invite.revoke` | A pending organization invitation is revoked with **Revoke** | the organization's login | `id` (the invitation id) |
8383 | `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 @@
527527 summary: "List issues and pull requests",
528528 description: "As on GitHub, this list includes pull requests. The total is in the `X-Total-Count` header.",
529529 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 },
531531 },
532532 "POST /repos/{owner}/{repo}/issues": {
533533 summary: "Create an issue",
@@ -539,7 +539,7 @@
539539 "GET /repos/{owner}/{repo}/issues/{number}": { summary: "Get an issue", access: "read" },
540540 "PATCH /repos/{owner}/{repo}/issues/{number}": {
541541 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.",
543543 access: "read",
544544 body: {
545545 title: ["string", "New title."],
@@ -548,7 +548,7 @@
548548 state_reason: ["string", "Why it was closed, like `completed` or `not_planned`."],
549549 labels: ["string[]", "Replace the labels."],
550550 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."],
552552 },
553553 },
554554 "GET /repos/{owner}/{repo}/milestones": {
@@ -560,12 +560,13 @@
560560 "GET /repos/{owner}/{repo}/milestones/{number}": { summary: "Get a milestone", description: "`number` is the milestone number. 404 `milestone not found`.", access: "read" },
561561 "POST /repos/{owner}/{repo}/milestones": {
562562 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.",
564564 access: "triage",
565565 status: 201,
566566 body: {
567567 title: ["string", "1 to 255 characters.", true],
568568 description: ["string", "At most 65,536 characters."],
569+ state: ["string", "`open` (default) or `closed`."],
569570 due_on: ["string", "ISO 8601 timestamp, or `null`."],
570571 },
571572 },
@@ -1190,15 +1191,15 @@
11901191 },
11911192 "POST /repos/{owner}/{repo}/actions/variables": {
11921193 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`.",
11941195 access: "admin",
11951196 status: 201,
11961197 body: { name: ["string", "Variable name.", true], value: ["string", "Variable value.", true] },
11971198 },
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" },
11991200 "PATCH /repos/{owner}/{repo}/actions/variables/{name}": {
12001201 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`.",
12021203 access: "admin",
12031204 status: 204,
12041205 body: { name: ["string", "New name (renames the variable)."], value: ["string", "New value."] },
@@ -1212,7 +1213,7 @@
12121213 },
12131214 "DELETE /repos/{owner}/{repo}/actions/variables/{name}": {
12141215 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`.",
12161217 access: "admin",
12171218 status: 204,
12181219 },
@@ -1247,7 +1248,7 @@
12471248 status: 204,
12481249 body: { value: ["string", "New value, at most 49,152 characters.", true] },
12491250 },
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 },
12511252
12521253 // ------------------------------------------------------------ webhooks
12531254 "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 @@
44 import { blockKey, chunkKey, compose, deleteKeys, finalKey, listStaged, parseBlockList, parseRange, sized, stagingPrefix, type BlobBucket, type ComposeStep } from "../lib/blob-store.js";
55 import { signBlobUrl, verifyBlobUrl, verifyRuntimeToken, type BlobRef, type RuntimeClaims } from "../lib/runtime-auth.js";
66 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";
911
1012 /**
1113 * GitHub's Actions runtime services, so the official actions and the toolkit
@@ -47,7 +49,6 @@
4749 return { status: 500, message: "internal error" };
4850 }
4951
50-const blobName = (a: ArtifactEntry) => (a.mimeType === ZIP ? `${a.name}.zip` : a.name);
5152 const readUrl = (env: Env, b: BlobRef, fileName?: string) => signBlobUrl(env.MASTER_KEY, origin(env), b, "r", nowS() + READ_URL_TTL_S, fileName);
5253
5354 /**
@@ -248,7 +249,7 @@
248249 const name = artifactName(req);
249250 const a = (await repoStub(c.env, claims.repo).rtJobArtifacts(claims.job, { name }))[0];
250251 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)) };
252253 });
253254
254255 twirp("ArtifactService", "DeleteArtifact", async (c, claims, req) => {
@@ -474,6 +475,7 @@
474475 const stub = repoStub(c.env, b.repo);
475476 const headers: Record<string, string> = { "accept-ranges": "bytes", "x-ms-blob-type": "BlockBlob", "content-type": "application/octet-stream" };
476477 let etag: string | null;
478+ let keys = [finalKey(b)];
477479 if (b.kind === "cache") {
478480 const e = await stub.rtCacheGet(b.id);
479481 if (!e) return azureError(404, "BlobNotFound", "The cache entry does not exist.");
@@ -482,14 +484,16 @@
482484 const a = await stub.rtArtifactGet(b.id);
483485 if (!a) return azureError(404, "BlobNotFound", "The artifact does not exist or has expired.");
484486 etag = a.etag;
485- const name = blobName(a);
487+ const name = artifactFileName(a);
486488 headers["content-type"] = a.mimeType;
487489 headers["content-disposition"] = `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}"; filename*=UTF-8''${encodeURIComponent(name)}`;
488490 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);
489493 }
490- const key = finalKey(b);
491- const head = await c.env.BUCKET.head(key);
494+ const head = await headFirst(c.env.BUCKET, keys);
492495 if (!head) return azureError(404, "BlobNotFound", "The specified blob does not exist.");
496+ const key = head.key;
493497 // Only the object that was committed: a write that landed after Finalize is not served.
494498 if (etag && head.etag !== etag) return azureError(409, "BlobModified", "The blob was modified after it was committed.");
495499 Object.assign(headers, { etag: head.httpEtag, "last-modified": head.uploaded.toUTCString() });
@@ -515,12 +519,6 @@
515519
516520 const P = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! });
517521
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-
524522 const idParam = (c: AppContext, name: string) => {
525523 const n = Number(c.req.param(name));
526524 if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(404, "Not Found");
@@ -550,8 +548,9 @@
550548
551549 actionsRest.get("/repos/:owner/:repo/actions/artifacts", async (c) => {
552550 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);
555554 return c.json({ total_count: res.total, artifacts: res.artifacts.map((a) => artifactJson(c, repo.id, a)) });
556555 });
557556
@@ -559,9 +558,8 @@
559558 const { repo, stub } = await loadRepo(c, P(c).owner, P(c).repo);
560559 const runId = c.req.param("runId");
561560 if (!/^[A-Za-z0-9]{1,64}$/.test(runId)) throw new HttpError(404, "Not Found");
562- const { perPage, page } = paging(c);
563561 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)) });
565563 });
566564
567565 actionsRest.get("/repos/:owner/:repo/actions/artifacts/:id", async (c) => {
@@ -578,21 +576,22 @@
578576 if (!a) throw new HttpError(404, "Not Found");
579577 // As on GitHub: an expired artifact is still listed, but its content is gone.
580578 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);
582580 });
583581
584582 actionsRest.delete("/repos/:owner/:repo/actions/artifacts/:id", async (c) => {
585583 const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo, "write");
586584 const deleted = await stub.rtArtifactDelete(idParam(c, "id"));
587585 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 });
589587 return c.body(null, 204);
590588 });
591589
592590 actionsRest.get("/repos/:owner/:repo/actions/caches", async (c) => {
593591 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);
596595 return c.json({ total_count: res.total, actions_caches: res.caches.map(cacheJson) });
597596 });
598597
@@ -602,7 +601,7 @@
602601 if (!key) throw new HttpError(422, "key is required");
603602 const deleted = await stub.rtDeleteCaches({ key, ref: c.req.query("ref") || undefined });
604603 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) });
606605 return c.json({ total_count: deleted.length, actions_caches: deleted.map(cacheJson) });
607606 });
608607
@@ -610,7 +609,7 @@
610609 const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo, "write");
611610 const deleted = await stub.rtDeleteCaches({ id: idParam(c, "id") });
612611 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] });
614613 return c.body(null, 204);
615614 });
616615
@@ -626,8 +625,8 @@
626625 const { caller, stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
627626 const b = (await c.req.json().catch(() => null)) as { default_workflow_permissions?: unknown; can_approve_pull_request_reviews?: unknown } | null;
628627 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" });
631630 return c.body(null, 204);
632631 });
633632
modifiedapps/diffinery/src/http/api.ts+17−4View file
@@ -6,7 +6,7 @@
66 import { encryptSecret } from "../lib/crypto.js";
77 import { publicJwk } from "../lib/jwt.js";
88 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";
1010 import type { JobInfo, RunInfo } from "../repo/actions.js";
1111 import type { CommitInfo } from "../repo/RepoDO.js";
1212 import type { QueueEntry } from "../repo/queue.js";
@@ -52,7 +52,7 @@
5252 return n;
5353 };
5454
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.
5656 api.route("/", imports);
5757
5858 // ---------------------------------------------------------------- meta & users
@@ -519,7 +519,11 @@
519519 const q = (k: string) => c.req.query(k) || undefined;
520520 const labels = q("labels")?.split(",").map((l) => l.trim()).filter(Boolean);
521521 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 *");
522525 const res = await stub.issues({
526+ milestone: ms === "none" || ms === "*" ? ms : ms ? Number(ms) : undefined,
523527 kind: kind === "pulls" ? "pull" : undefined,
524528 state: (q("state") as "open" | "closed" | "all") ?? "open",
525529 labels,
@@ -552,8 +556,17 @@
552556 });
553557 api.patch("/repos/:owner/:repo/issues/:number", async (c) => {
554558 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+ }
557570 return c.json((await issuesOut(c, repo, stub, [issue]))[0]);
558571 });
559572 api.get("/repos/:owner/:repo/issues/:number/comments", async (c) => {
modifiedapps/diffinery/src/http/imports.ts+6−21View file
@@ -292,24 +292,6 @@
292292 return c.json(milestoneJson(c, await stub.saveMilestone(num(c), milestoneInput(await body<Record<string, unknown>>(c)), caller.login)));
293293 });
294294
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-
313295 // ---------------------------------------------------------------- organization Actions variables
314296
315297 async function orgFor(c: AppContext, admin: boolean) {
@@ -339,7 +321,7 @@
339321 if (!VAR_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid variable name");
340322 if (typeof value !== "string" || value.length > 48 * 1024) throw new HttpError(422, "value must be a string of at most 48 KB");
341323 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" } }));
343325 };
344326
345327 imports.post("/orgs/:org/actions/variables", async (c) => {
@@ -363,7 +345,10 @@
363345 imports.delete("/orgs/:org/actions/variables/:name", async (c) => {
364346 requireAccountScope(c, "admin");
365347 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" } }));
368353 return c.body(null, 204);
369354 });
modifiedapps/diffinery/src/http/misc.ts+5−3View file
@@ -117,11 +117,13 @@
117117 });
118118
119119 // ---------------------------------------------------------------- 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.
120122
121123 misc.get(`${R}/actions/variables/:name`, async (c) => {
122124 const { stub } = await load(c, "write");
123125 const name = c.req.param("name").toUpperCase();
124- const vars = await stub.variables();
126+ const vars = await stub.variables(false);
125127 if (!(name in vars)) throw new HttpError(404, "Not Found");
126128 return c.json({ name, value: vars[name], created_at: null, updated_at: null });
127129 });
@@ -130,7 +132,7 @@
130132 refuseAgent(caller, "variables");
131133 const b = await jsonBody<{ name?: string; value?: string }>(c);
132134 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");
134136 await stub.setVariable(b.name, b.value);
135137 auditRepo(c, caller, repo, "repo.variable.set", { name: b.name.toUpperCase() });
136138 return c.json({}, 201);
@@ -139,7 +141,7 @@
139141 const { stub, caller, repo } = await load(c, "admin");
140142 refuseAgent(caller, "variables");
141143 const name = c.req.param("name");
142- const vars = await stub.variables();
144+ const vars = await stub.variables(false);
143145 if (!(name.toUpperCase() in vars)) throw new HttpError(404, "Not Found");
144146 const b = await jsonBody<{ name?: string; value?: string }>(c);
145147 const next = typeof b.name === "string" && b.name ? b.name : name;
modifiedapps/diffinery/src/http/runner.ts+3−3View file
@@ -111,9 +111,9 @@
111111 const { token, repoId } = jobAuth(c.req.raw, jobId);
112112 const stub = repoStub(c.env, repoId);
113113 // 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"));
117117 if (room === 0) throw new HttpError(413, "the repository's artifact storage quota is used up; delete artifacts or wait for them to expire");
118118 const size = await storeBody(c, keys[0]!, room);
119119 await stub.artifactSaved(jobId, token, c.req.param("name"), size);
modifiedapps/diffinery/src/lib/directory.ts+12−3View file
@@ -775,11 +775,20 @@
775775 return { added, skipped };
776776 }
777777
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+ */
779784 private async syncOrgVariables(orgId: string): Promise<void> {
780785 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`);
783792 }
784793
785794 /** 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 @@
870870 cacheSaved(...a: Parameters<Actions["cacheSaved"]>) {
871871 return this.actions.cacheSaved(...a);
872872 }
873+ artifactUploadKeys(...a: Parameters<Actions["artifactUploadKeys"]>) {
874+ return this.actions.artifactUploadKeys(...a);
875+ }
873876 artifactSaved(...a: Parameters<Actions["artifactSaved"]>) {
874877 return this.actions.artifactSaved(...a);
875878 }
@@ -981,9 +984,6 @@
981984 rtUnstage(...a: Parameters<Runtime["unstage"]>) {
982985 return this.runtime.unstage(...a);
983986 }
984- artifactRoom() {
985- return this.runtime.artifactRoom();
986- }
987987 rtCheckCommitSize(...a: Parameters<Runtime["checkCommitSize"]>) {
988988 return this.runtime.checkCommitSize(...a);
989989 }
modifiedapps/diffinery/src/repo/actions.ts+41−27View file
@@ -17,7 +17,6 @@
1717 } from "@diffinery/workflow-yaml";
1818 import { decryptSecret, encryptSecret } from "../lib/crypto.js";
1919 import { Directory } from "../lib/directory.js";
20-import { finalKey } from "../lib/blob-store.js";
2120 import { getFirst, headFirst, legacyArtifactKeys, legacyCacheKeys, logKeys } from "../lib/storage-keys.js";
2221 import { permissionsForJob, type JobPermissions } from "../lib/job-permissions.js";
2322 import { signRuntimeToken } from "../lib/runtime-auth.js";
@@ -26,6 +25,7 @@
2625 import type { Env } from "../env.js";
2726 import type { RepoDO } from "./RepoDO.js";
2827 import { completePayload } from "./event-payloads.js";
28+import { CACHE_IDLE_MS, CACHE_LIMIT_BYTES, artifactFileName, artifactObjectKeys } from "./runtime.js";
2929 import { atLeast, type Caller } from "./types.js";
3030 import { isJobCaller } from "./token-policy.js";
3131 import { ReusableError, expandReusable, localKey, parseCallRef, type Expanded, type Source } from "./reusable.js";
@@ -1156,8 +1156,9 @@
11561156
11571157 /**
11581158 * 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.
11611162 */
11621163 async packagesPermission(token: string): Promise<"none" | "read" | "write"> {
11631164 const auth = await this.runnerAuth(token);
@@ -1382,7 +1383,7 @@
13821383 }
13831384
13841385 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());
13861387 else this.sql.exec("INSERT OR REPLACE INTO variables (name, value, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), value, now());
13871388 }
13881389
@@ -1402,7 +1403,7 @@
14021403 * (repo/runtime.ts): a job saves to its own ref and restores from its own ref,
14031404 * then the pull request's base, then the default branch, so one branch or pull
14041405 * 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.
14061407 */
14071408 async cacheLookup(jobId: string, token: string, key: string, restoreKeys: string[]): Promise<{ key: string; stored: string } | null> {
14081409 await this.authJob(jobId, token);
@@ -1455,13 +1456,17 @@
14551456 return legacyCacheKeys(this.repo.meta.repoId, key);
14561457 }
14571458
1459+ /** Records a saved entry; returns the R2 keys of the entries it evicted, for the caller to delete. */
14581460 async cacheSaved(jobId: string, token: string, key: string, size: number): Promise<string[]> {
14591461 const { stored } = await this.cacheSaveKeys(jobId, token, key);
14601462 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));
14631468 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) {
14651470 const old = this.sql.exec<{ key: string; size: number }>("SELECT key, size FROM caches ORDER BY last_used LIMIT 1").one();
14661471 this.sql.exec("DELETE FROM caches WHERE key = ?", old.key);
14671472 evicted.push(...this.cacheR2Keys(old.key));
@@ -1470,13 +1475,24 @@
14701475 return evicted;
14711476 }
14721477
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 }> {
14741484 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() };
14771487 }
14781488
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. */
14801496 async artifactKeysFor(jobId: string, token: string, name: string): Promise<string[]> {
14811497 const job = await this.authJob(jobId, token);
14821498 return this.artifactR2Keys(job.run_id, name);
@@ -1491,22 +1507,20 @@
14911507 * A run's artifacts: those uploaded through the artifact service (zip files, or
14921508 * single unarchived files) and older runners' tarballs.
14931509 */
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 }> {
14951511 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));
15101524 }
15111525
15121526 // ---------------------------------------------------------------- sandboxes for agents
modifiedapps/diffinery/src/repo/collab.ts+7−0View file
@@ -237,6 +237,7 @@
237237 // GitHub list filters (REST `issues` and `pulls` query parameters).
238238 labels?: string[];
239239 assignee?: string;
240+ milestone?: number | "none" | "*";
240241 since?: string;
241242 head?: string;
242243 base?: string;
@@ -276,6 +277,12 @@
276277 where.push("EXISTS (SELECT 1 FROM json_each(assignees) WHERE lower(value) = lower(?))");
277278 args.push(opts.assignee);
278279 }
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+ }
279286 if (opts.since) {
280287 where.push("updated_at >= ?");
281288 args.push(opts.since);
modifiedapps/diffinery/src/repo/imports.ts+13−1View file
@@ -336,7 +336,19 @@
336336 if (!input.title?.trim()) throw new HttpError(422, "title is required");
337337 this.sql.exec("INSERT INTO counters (name, value) VALUES ('milestone', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1");
338338 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+ );
340352 return this.milestone(n);
341353 }
342354 const m = this.milestone(number);
modifiedapps/diffinery/src/repo/runtime.ts+64−14View file
@@ -1,6 +1,7 @@
11 import type { Env } from "../env.js";
22 import { dropStaged, finalKey, runComposeStep, stagingPrefix, stepKeys, type BlobBucket, type ComposeStep } from "../lib/blob-store.js";
33 import { READ_ALL, roleForPermissions, type JobPermissions } from "../lib/job-permissions.js";
4+import { legacyArtifactKeys } from "../lib/storage-keys.js";
45 import { HttpError, now } from "../lib/util.js";
56 import type { RepoDO } from "./RepoDO.js";
67 import type { Role } from "./types.js";
@@ -12,10 +13,16 @@
1213 * wire protocols and moves the bytes; this module decides what may be read or
1314 * written. Every call made with a runtime token names the job, and works only
1415 * 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.
1522 */
1623
1724 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 };
1926
2027 export type JobScope = {
2128 runId: string;
@@ -25,11 +32,11 @@
2532 };
2633
2734 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 };
2936
3037 /** Per-repository cache budget, and how long an unused entry is kept (GitHub's limits). */
3138 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;
3340 /** Artifact retention: `retention-days` is clamped to 1..90 days, GitHub's default maximum. */
3441 export const MAX_RETENTION_DAYS = 90;
3542 /** How long an expired artifact stays listed (as `expired: true`) after its content is deleted. */
@@ -41,6 +48,16 @@
4148
4249 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 });
4350
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+
4461 export class Runtime {
4562 constructor(
4663 private repo: RepoDO,
@@ -166,9 +183,7 @@
166183
167184 /** Bytes of live artifacts (from both upload paths) plus everything staged by uploads in progress. */
168185 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;
172187 }
173188
174189 /**
@@ -279,6 +294,12 @@
279294 if (staged) for (const id of ids.slice(0, 20)) await dropStaged(this.bucket, { repo: this.repoId, kind, id });
280295 }
281296
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+
282303 /**
283304 * When a job ends, its unfinished uploads go: their rows, so they stop counting
284305 * against the budgets, and their objects.
@@ -362,6 +383,7 @@
362383 expiresAt: r.expires_at,
363384 expired: r.status === "expired" || r.expires_at <= now(),
364385 etag: r.etag,
386+ legacy: r.legacy === 1,
365387 };
366388 };
367389
@@ -418,6 +440,27 @@
418440 return this.artifactGet(a.id)!;
419441 }
420442
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+
421464 /** A finished artifact; with `includeExpired` (the REST API), also one past its retention. */
422465 artifactGet(id: number, o: { includeExpired?: boolean } = {}): ArtifactEntry | null {
423466 const r = o.includeExpired
@@ -428,10 +471,11 @@
428471
429472 /**
430473 * 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.
432476 * A numeric run id (github.run_id, the run number) covers all of the run's attempts.
433477 */
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[] {
435479 const numeric = /^\d+$/.test(runId);
436480 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 > ?"])];
437481 const args: unknown[] = [numeric ? Number(runId) : runId, ...(filter.includeExpired ? [] : [now()])];
@@ -443,12 +487,13 @@
443487 where.push("id = ?");
444488 args.push(filter.id);
445489 }
490+ if (filter.legacy === false) where.push("legacy = 0");
446491 return this.sql.exec<ArtifactRow>(`SELECT * FROM rt_artifacts WHERE ${where.join(" AND ")} ORDER BY id DESC`, ...args).toArray().map(this.toArtifact);
447492 }
448493
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). */
450495 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 });
452497 }
453498
454499 /** REST: GET /actions/artifacts, expired artifacts included (as GitHub lists them). */
@@ -470,7 +515,8 @@
470515 async artifactDelete(id: number): Promise<ArtifactEntry | null> {
471516 const a = this.artifactGet(id, { includeExpired: true });
472517 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);
474520 return a;
475521 }
476522
@@ -488,12 +534,16 @@
488534 */
489535 async expireArtifacts(): Promise<void> {
490536 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 }));
493542 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);
495544 for (const id of unfinished) this.sql.exec("DELETE FROM rt_artifacts WHERE id = ?", id);
496545 if (ready.length) await this.dropObjects("artifact", ready, false);
546+ if (legacy.length) await this.dropLegacyArtifacts(legacy);
497547 if (unfinished.length) await this.dropObjects("artifact", unfinished);
498548 const forgotten = new Date(Date.now() - EXPIRED_LISTED_DAYS * 86400_000).toISOString();
499549 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 @@
6161 CREATE INDEX IF NOT EXISTS jobs_run ON jobs(run_id);
6262 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));
6363 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.
6465 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));
6566 CREATE TABLE IF NOT EXISTS merge_queue (
6667 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 @@
163164 ["rt_caches", "etag", "TEXT"],
164165 ["rt_artifacts", "staged", "INTEGER NOT NULL DEFAULT 0"],
165166 ["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"],
166169 // Who created a check run over the REST API (null for Diffinery Actions), and GitHub's numeric id.
167170 ["checks", "creator_id", "TEXT"],
168171 ["checks", "creator_login", "TEXT"],
@@ -190,4 +193,13 @@
190193 const has = sql.exec<{ n: number }>(`SELECT count(*) AS n FROM pragma_table_info('${table}') WHERE name = ?`, column).one().n > 0;
191194 if (!has) sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
192195 }
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");
193205 }
modifiedapps/diffinery/src/repo/token-policy.ts+3−2View file
@@ -48,7 +48,8 @@
4848 return row ? { ...LEGACY, ...(JSON.parse(row.value) as Partial<WorkflowSettings>) } : { ...LEGACY };
4949 }
5050
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 {
5253 if (caller.role !== "admin") throw new HttpError(403, "admin access required");
5354 if (caller.kind !== "user") throw new HttpError(403, "only people can change what workflows are allowed to do");
5455 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 @@
5657 const next = { ...this.settings(), ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) } as WorkflowSettings;
5758 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", SETTINGS_KEY, JSON.stringify(next));
5859 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() });
6061 return next;
6162 }
6263
modifiedapps/diffinery/src/web/collab.tsx+14−4View file
@@ -118,17 +118,19 @@
118118 const label = c.req.query("label") ?? "";
119119 const q = (c.req.query("q") ?? "").slice(0, 200);
120120 const author = c.req.query("author") ?? "";
121+ const ms = /^\d+$/.test(c.req.query("milestone") ?? "") ? Number(c.req.query("milestone")) : undefined;
121122 const page = pageNum(c);
122123 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([
125126 stub.issues({ ...filter, state, limit: per, offset: (page - 1) * per }),
126127 stub.issues({ ...filter, state: state === "open" ? "closed" : "open", limit: 1 }),
127128 stub.labels(),
129+ ms === undefined ? null : stub.milestones("all").then((all) => all.find((m) => m.number === ms) ?? null),
128130 ]);
129131 const colors = new Map(labels.map((l) => [l.name, l.color]));
130132 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 })}`;
132134 const openCount = state === "open" ? list.total : other.total;
133135 const closedCount = state === "closed" ? list.total : other.total;
134136 const isAccount = c.get("principal").type === "account";
@@ -141,6 +143,7 @@
141143 <form method="get" action={`${base}/${path}`} class="row grow" style="max-width:640px">
142144 <input type="hidden" name="state" value={state === "closed" ? "closed" : ""} />
143145 {label ? <input type="hidden" name="label" value={label} /> : null}
146+ {ms !== undefined ? <input type="hidden" name="milestone" value={String(ms)} /> : null}
144147 <input type="search" name="q" value={q} placeholder={`Search ${kind === "pull" ? "pull requests" : "issues"}… ( / )`} class="grow" style="flex:1" />
145148 </form>
146149 <div class="row">
@@ -177,6 +180,11 @@
177180 Author: <b>{author}</b> <a href={href({ author: undefined })}>clear</a>
178181 </span>
179182 ) : 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}
180188 {labels.length ? (
181189 <details class="menu">
182190 <summary class="btn sm">Label ▾</summary>
@@ -519,7 +527,7 @@
519527 <h3>Milestone</h3>
520528 {x.issue.milestone ? (
521529 <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>{" "}
523531 {x.issue.milestone.state === "closed" ? <span class="badge">closed</span> : null}
524532 {x.issue.milestone.dueOn ? <span class="muted"> · due {x.issue.milestone.dueOn.slice(0, 10)}</span> : null}
525533 </p>
@@ -1213,6 +1221,8 @@
12131221
12141222 export function registerCollab(web: WebApp): void {
12151223 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")}`)),
12161226 web.get("/:owner/:repo/pulls", (c) => listPage(c, "pull"));
12171227
12181228 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 @@
11761176 assert.deepEqual(await restored(), { builtin: "refs/heads/main", real: "refs/heads/main" });
11771177 const caches = await ok("GET", `/repos/${owner}/scopes/actions/caches?per_page=100`);
11781178 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"/);
11791183
11801184 // retention-days (the built-in and the real upload-artifact), capped at 90 days.
11811185 const days = (iso) => Math.round((Date.parse(iso) - Date.now()) / 86_400_000);
@@ -1287,6 +1291,64 @@
12871291 assert.equal(await get(real), 401);
12881292 });
12891293
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+
12901352 const hasDocker = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === 0;
12911353
12921354 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 @@
550550 const fromAdmin = await ok("POST", `${R}/issues`, { title: "from the owner", labels: ["bug"] });
551551 assert.equal(fromAdmin.labels[0].name, "bug");
552552 // 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);
554555 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]);
556557
557558 // ------------------------------------------------------------ settings changed through the API are on the record
558559 await status(204, "PUT", `${R}/collaborators/rita`, { permission: "push" });
@@ -1215,3 +1216,69 @@
12151216 // Job and sandbox tokens can't start sandboxes, whose tokens would outlive them.
12161217 await status(403, "POST", `/repos/${OWNER}/minting/diffinery/sandboxes`, {}, job);
12171218 });
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 @@
11 import { describe, expect, it } from "vitest";
22 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";
33 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";
56 import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
67 import type { RepoDO } from "../../src/repo/RepoDO.js";
78 import type { Env } from "../../src/env.js";
@@ -9,7 +10,8 @@
910 import { signBlobUrl, signRuntimeToken, verifyBlobUrl, verifyRuntimeToken } from "../../src/lib/runtime-auth.js";
1011 import { b64, fromB64url } from "../../src/lib/util.js";
1112 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";
1315 import { numericId } from "../../src/http/gh.js";
1416
1517 const MASTER = b64(new Uint8Array(32).fill(7));
@@ -397,8 +399,13 @@
397399 sql.exec(REPO_SCHEMA);
398400 migrateRepoSchema(sql as unknown as SqlStorage);
399401 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() });
402409 let n = 0;
403410 /** A running job of a run on `ref`; `payload` is the run's event payload. */
404411 const job = (ref: string, payload: Record<string, unknown> = {}) => {
@@ -424,7 +431,7 @@
424431 await rt.cacheCommit(jobId, r!.id, 10, "etag");
425432 };
426433 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 };
428435 }
429436
430437 describe("cache scopes (as on GitHub)", () => {
@@ -525,3 +532,114 @@
525532 expect(f.rt.artifactGet(keep.id, { includeExpired: true })).toBeNull();
526533 });
527534 });
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+});