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.

Make failed-job and single-job re-runs GitHub-compatible (#151)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Canberk Demirci authored · GitHub committed signed1 parent 4372560commit 6074ea77cf4560239acc053623dd5f8a1a1db9d5Browse files
20 files changed+726−39
Files (20)
modifiedapps/cli/src/commands/run.ts+9−4View file
@@ -182,15 +182,20 @@
182182 const rerun: Command = {
183183 name: "run rerun",
184184 summary: "Re-run a workflow run",
185- args: "<run-id>",
186- options: { failed: { type: "boolean", desc: "Only re-run failed jobs (where supported)" } },
185+ args: "[run-id]",
186+ options: {
187+ failed: { type: "boolean", desc: "Only re-run the failed jobs and the jobs that depend on them" },
188+ job: { type: "string", short: "j", desc: "Re-run this job and the jobs that depend on it (no run id needed)", arg: "JOB-ID" },
189+ },
187190 repo: true,
188191 json: true,
189- minArgs: 1,
190192 maxArgs: 1,
191193 async run(ctx, o, [id]) {
192194 const repo = await ctx.repo();
193- const r = await (await ctx.authedApi()).post<{ run_id: string }>(`${repoPath(repo)}/actions/runs/${enc(id!)}/${o.failed ? "rerun-failed-jobs" : "rerun"}`);
195+ const job = str(o, "job");
196+ if (!job && !id) throw new CliError("give a run id, or --job <job-id>", 2);
197+ const path = job ? `actions/jobs/${enc(job)}/rerun` : `actions/runs/${enc(id!)}/${o.failed ? "rerun-failed-jobs" : "rerun"}`;
198+ const r = await (await ctx.authedApi()).post<{ run_id: string }>(`${repoPath(repo)}/${path}`);
194199 if (ctx.json) return json(ctx.io, r);
195200 ctx.out(`${ctx.c.green("✓")} Re-running as run ${r.run_id}`);
196201 },
modifiedapps/cli/test/misc.test.ts+24−0View file
@@ -52,6 +52,30 @@
5252 });
5353 });
5454
55+describe("run rerun", () => {
56+ let srv: FakeServer;
57+ beforeEach(async () => {
58+ srv = await fakeServer();
59+ });
60+ afterEach(() => srv.close());
61+
62+ it("re-runs a run, its failed jobs, or one job", async () => {
63+ for (const p of ["runs/run1/rerun", "runs/run1/rerun-failed-jobs", "jobs/job1/rerun"]) srv.on("POST", `/api/v3/repos/o/r/actions/${p}`, { status: 201, body: { run_id: "run2" } });
64+ const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" });
65+ for (const [args, p] of [
66+ [["run1"], "runs/run1/rerun"],
67+ [["run1", "--failed"], "runs/run1/rerun-failed-jobs"],
68+ [["--job", "job1"], "jobs/job1/rerun"],
69+ ] as const) {
70+ const r = await run(["run", "rerun", ...args, "-R", "o/r"], { env });
71+ expect(r.code).toBe(0);
72+ expect(r.stdout).toContain("Re-running as run run2");
73+ expect(srv.find("POST", `/api/v3/repos/o/r/actions/${p}`)).toHaveLength(1);
74+ }
75+ expect((await run(["run", "rerun", "-R", "o/r"], { env })).code).toBe(2);
76+ });
77+});
78+
5579 describe("GitHub client", () => {
5680 let srv: FakeServer;
5781 beforeEach(async () => {
modifiedapps/diffinery/docs/actions/caching-and-artifacts.md+1−1View file
@@ -391,7 +391,7 @@
391391 - The job's `GITHUB_TOKEN` can read another run of its own repository when the job has `actions: read` (or the repository default gives it). It can read artifacts of other **public** repositories on Diffinery, but not of other private ones; use a personal or agent token with read access for those. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions).
392392 - `run-id` without `github-token` is ignored, and the current run is read.
393393
394-Without these inputs, `actions/download-artifact` reads artifacts from the job's own run only. Jobs of a [reusable workflow](/docs/actions/reusable-workflows) are part of the caller's run, so the caller and the called workflow can exchange artifacts. A re-run attempt is a separate run for this purpose: its jobs don't see the artifacts of earlier attempts without `run-id`.
394+Without these inputs, `actions/download-artifact` reads artifacts from the job's own run only. Jobs of a [reusable workflow](/docs/actions/reusable-workflows) are part of the caller's run, so the caller and the called workflow can exchange artifacts. A re-run attempt is a separate run for this purpose: its jobs don't see the artifacts of earlier attempts without `run-id`, except those of the jobs a failed-job or single-job re-run kept from an earlier attempt (see [Re-run a run](/docs/actions/overview#re-run-a-run)), which belong to the new attempt too.
395395
396396 ### Uploading the same name twice
397397
modifiedapps/diffinery/docs/actions/environments.md+1−1View file
@@ -234,7 +234,7 @@
234234
235235 ### Re-runs
236236
237-A re-run is a new run: its jobs wait again and need a new approval. The timer starts again too.
237+A re-run is a new run: its jobs wait again and need a new approval. The timer starts again too. A job that a failed-job or single-job re-run keeps from the earlier attempt doesn't run again, so it neither waits nor deploys.
238238
239239 ### Review with the API
240240
modifiedapps/diffinery/docs/actions/overview.md+22−7View file
@@ -270,7 +270,7 @@
270270 | `diffinery run view <run-id>` | Shows a run and its jobs, with job IDs and failure reasons. |
271271 | `diffinery run watch <run-id>` | Polls every 3 seconds (`-i, --interval SECONDS`) and prints each job's status changes. Exits 0 if the run succeeds and 1 otherwise, so you can use it in scripts. On failure it prints the `diffinery run logs` command for the failed job. |
272272 | `diffinery run logs <job-id>` | Prints a job's log. With `--json`, prints the log lines as JSON objects with `seq`, `t` (timestamp), `step` (step index, `-1` for job setup) and `text`. |
273-| `diffinery run rerun <run-id>` | Re-runs a run (see [Re-run a run](#re-run-a-run)). |
273+| `diffinery run rerun <run-id>` | Re-runs a run; `--failed` re-runs only its failed jobs, and `--job <job-id>` one job (see [Re-run a run](#re-run-a-run)). |
274274 | `diffinery run cancel <run-id>` | Cancels a queued or running run. |
275275 | `diffinery workflow list` | Lists the workflows on the default branch, whether each is `active` or `invalid`, and their triggers. |
276276 | `diffinery workflow validate [files...]` | Checks workflow files locally (see [Workflow syntax](/docs/actions/workflow-syntax#validate-workflow-files)). |
@@ -303,14 +303,15 @@
303303 | `GET /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/logs` | The log as text (`<timestamp> <line>` per line). Add `format=json` for JSON lines, and `after=<seq>` to get only lines after a sequence number. |
304304 | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The run's artifacts (every attempt when `run_id` is a run number), expired ones included. See [Download artifacts with the API](/docs/actions/caching-and-artifacts#download-artifacts-with-the-api) for the other artifact and cache endpoints. |
305305 | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` | Cancels the run (`202`). Needs write. |
306-| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` | Re-runs the run (`201`, `{"run_id": ...}`). Needs write. |
307-| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | Same as `rerun` (see below). |
306+| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` | Re-runs every job of the run (`201`, `{"run_id": ...}`, the new attempt's ID). Needs write. |
307+| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | Re-runs the run's failed jobs and the jobs that depend on them (`201`, `{"run_id": ...}`). See below. |
308+| `POST /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/rerun` | Re-runs one job and the jobs that depend on it (`201`, `{"run_id": ...}`). See below. |
308309
309310 Run objects use GitHub's field names (`id`, `node_id`, `run_number`, `run_attempt`, `name`, `display_title`, `path`, `workflow_id`, `event`, `head_branch`, `head_sha`, `sha`, `ref`, `status`, `conclusion`, `actor` and `triggering_actor` as user objects, `created_at`, `updated_at`, `run_started_at`, `url`, `html_url`, `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url`, `rerun_url`, `workflow_url`, `previous_attempt_url` and `pull_requests`) plus `pull_request_number`. Job objects carry GitHub's fields (`run_id`, `run_attempt`, `head_sha`, `head_branch`, `html_url`, `started_at`, `completed_at`, `workflow_name`, `steps` with `number`) next to Diffinery's own camelCase fields, which the CLI reads. `head_sha` is the commit checks are reported on (the pull request head for pull request runs) and `sha` is the commit the run checked out. Run and job IDs are 32-character hexadecimal strings. Wherever a path takes `{run_id}`, the run number works too and means its latest attempt; this is what `github.run_id` and `GITHUB_RUN_ID` hold inside a job. A job's `check_run_url` points at `GET /repos/{owner}/{repo}/check-runs/{job_id}`, which returns the job's check run. While a job is running, one logs request returns at most 5,000 lines; page with `after`. The runs, jobs and artifacts lists send a `Link` header with `next`, `prev`, `first` and `last` URLs, so Octokit's `paginate()` works. Errors have a `message` and a `documentation_url`. Every endpoint is listed in the [API reference](/docs/api).
310311
311312 ### From MCP
312313
313-Agents can use `list_workflow_runs`, `get_workflow_run`, `get_job_logs` (the last 400 lines by default, set `tail` for more), `rerun_workflow`, `cancel_workflow` and `dispatch_workflow`. See [MCP tools](/docs/reference/mcp-tools).
314+Agents can use `list_workflow_runs`, `get_workflow_run`, `get_job_logs` (the last 400 lines by default, set `tail` for more), `rerun_workflow` (with `failed_only` for the failed jobs only), `rerun_job`, `cancel_workflow` and `dispatch_workflow`. See [MCP tools](/docs/reference/mcp-tools).
314315
315316 ## Cancel a run
316317
@@ -320,20 +321,34 @@
320321
321322 ## Re-run a run
322323
323-Click **Re-run all jobs** on a completed run's page, run `diffinery run rerun <run-id>`, call `POST /actions/runs/{run_id}/rerun`, or use the `rerun_workflow` MCP tool. You need write access.
324+You can re-run a run once it has finished, in three ways. You need write access.
324325
325-A re-run is a new run with:
326+| What runs again | Web | CLI | API | MCP |
327+| --- | --- | --- | --- | --- |
328+| Every job | **Re-run all jobs** on the run's page | `diffinery run rerun <run-id>` | `POST /actions/runs/{run_id}/rerun` | `rerun_workflow` |
329+| The failed jobs | **Re-run failed jobs** on the run's page (shown when a job failed, was cancelled or timed out) | `diffinery run rerun <run-id> --failed` | `POST /actions/runs/{run_id}/rerun-failed-jobs` | `rerun_workflow` with `failed_only: true` |
330+| One job | **Re-run this job** on the job's page | `diffinery run rerun --job <job-id>` | `POST /actions/jobs/{job_id}/rerun` | `rerun_job` |
326331
332+Each re-run is a new attempt of the run, with:
333+
327334 - the same workflow file (read again from the original commit), commit, ref, event, event payload and inputs,
328335 - the same run number and an attempt number one higher (shown as `attempt 2`, and available as `github.run_attempt`), so `github.run_id` stays the same,
329336 - a new run ID in the API, and you as the actor,
330337 - whether its code came from an agent, kept from the original run whoever re-runs it (see [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)).
331338
339+**Re-run failed jobs** runs again, as on GitHub, every job and matrix leg whose conclusion was `failure`, `cancelled` or `timed_out`, and every job that depends on one of them through `needs`, directly or not (all of a dependent job's matrix legs run again). A job that never started because the run was cancelled first runs too. Jobs inside a [reusable workflow](/docs/actions/reusable-workflows) count like any other job, and a job that needs the calling job depends on every job inside the call. The other jobs and matrix legs are kept from the earlier attempt as they were: their conclusion, outputs, steps, log, summary and artifacts. They don't run again, start no runner and use no minutes, and they don't count toward `max-parallel`, so a matrix with `fail-fast` can't cancel a leg that already passed. Their check runs point at the new attempt with the same conclusion. On the run's page they're marked **Kept from an earlier attempt**, and in the API a kept job has `carriedFrom`, the ID of the job that ran. A run with no failed, cancelled or timed out job answers `409 this run has no failed jobs to re-run`.
340+
341+**Re-run this job** works the same way for one job (one matrix leg): it runs again with every job that depends on it, and every other job is kept, whatever its conclusion.
342+
343+The run's conclusion counts kept jobs like the others. The body field `enable_debug_logging` of the re-run endpoints is accepted and ignored.
344+
345+Failed-job and single-job re-runs need a finished run (`409 this run hasn't finished yet; ...` otherwise). If what failed was the workflow's set-up (the file couldn't be read or a reusable workflow couldn't be loaded), every job runs again.
346+
332347 A workflow's own `GITHUB_TOKEN` can't re-run runs: `403 a workflow's GITHUB_TOKEN cannot re-run workflows`.
333348
334349 It fails with `422 workflow file no longer valid at this commit` if the file can't be loaded from that commit any more.
335350
336-> **Note:** Diffinery always re-runs every job. `diffinery run rerun --failed` and `POST .../rerun-failed-jobs` are accepted for compatibility and behave like a full re-run. On GitHub a re-run keeps the same run ID in the API; on Diffinery the API's `id` is new, while `github.run_id` (the run number) stays the same.
351+> **Note:** On GitHub a re-run keeps the same run ID in the API, and a kept job keeps its job ID and the attempt it ran in. On Diffinery the new attempt has a new `id` (the run number, `github.run_id`, stays the same), a kept job is a copy with its own ID in the new attempt, and its `run_attempt` is the new attempt's.
337352
338353 ## Job summaries
339354
modifiedapps/diffinery/docs/actions/workflow-permissions.md+1−1View file
@@ -226,7 +226,7 @@
226226
227227 Most of these endpoints need the admin role, which a job token never has, so the response is `403 admin access required`. The others answer `403 this endpoint is not available to a workflow's GITHUB_TOKEN`.
228228
229-A job token can't re-run workflows either: `POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun` (and `/rerun-failed-jobs`) fails with `403 a workflow's GITHUB_TOKEN cannot re-run workflows`.
229+A job token can't re-run workflows either: `POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun` (and `/rerun-failed-jobs`, and `POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun`) fails with `403 a workflow's GITHUB_TOKEN cannot re-run workflows`.
230230
231231 ### The 403 response
232232
modifiedapps/diffinery/docs/actions/workflow-syntax.md+1−1View file
@@ -376,7 +376,7 @@
376376 4. A deployment is recorded for the run's commit and ref, with the task `deploy`, marked as a production deployment when the environment is named `production` (in any case), and a deployment status `in_progress` whose log URL is the job's log. When `url` is an `http://` or `https://` URL, it's stored as the status's environment URL.
377377 5. The environment's secrets are added to the `secrets` context and its variables to the `vars` context. They win over repository and organization secrets and variables with the same names. `GITHUB_TOKEN` and `DIFFINERY_TOKEN` can't be overridden.
378378
379-When the job finishes, the deployment gets a final status: `success` when the job succeeded, `inactive` when it was cancelled, and `failure` otherwise (including timeouts). Each attempt of a re-run records its own deployment. When the job succeeds, older successful deployments to the same environment become `inactive` (newer ones are left alone, and production deployments are never made inactive this way). Read deployments with `GET /api/v3/repos/{owner}/{repo}/deployments`; webhooks subscribed to `deployment` get a `created` event for each deployment, and those subscribed to `deployment_status` get each status.
379+When the job finishes, the deployment gets a final status: `success` when the job succeeded, `inactive` when it was cancelled, and `failure` otherwise (including timeouts). Each attempt of a re-run that runs the job again records its own deployment. When the job succeeds, older successful deployments to the same environment become `inactive` (newer ones are left alone, and production deployments are never made inactive this way). Read deployments with `GET /api/v3/repos/{owner}/{repo}/deployments`; webhooks subscribed to `deployment` get a `created` event for each deployment, and those subscribed to `deployment_status` get each status.
380380
381381 Environment secrets aren't always handed out, even when the branch policy allows the ref:
382382
modifiedapps/diffinery/docs/agents/mcp.md+2−1View file
@@ -190,7 +190,8 @@
190190 | `list_workflow_runs` | Yes | The 30 newest runs, filtered by `branch`, `sha`, `pr_number` or `status` | read |
191191 | `get_workflow_run` | Yes | A run with its jobs, their status, conclusion and failure reason | read |
192192 | `get_job_logs` | Yes | A job's log as text; the last `tail` lines (default 400), with `complete` and `truncated` flags | read |
193-| `rerun_workflow` | No | Re-run a run | write |
193+| `rerun_workflow` | No | Re-run a finished run as a new attempt; with `failed_only`, only its failed jobs and the jobs that depend on them | write |
194+| `rerun_job` | No | Re-run one job (`job_id`) of a finished run and the jobs that depend on it | write |
194195 | `cancel_workflow` | No | Cancel a queued or running run | write |
195196 | `dispatch_workflow` | No | Start a `workflow_dispatch` run of `workflow` (for example `deploy.yml`) on `ref` with `inputs` | write |
196197
modifiedapps/diffinery/docs/collaboration/checks-and-statuses.md+1−1View file
@@ -29,7 +29,7 @@
2929 - linked to the job's page (`https://diffinery.com/acme/website/actions/runs/{run_id}/job/{job_id}`);
3030 - reported by the app `diffinery-actions`.
3131
32-Re-running a job replaces the check of the same name on the same commit. Check runs from Actions can't be changed over the REST API: a `PATCH` fails with `check runs from Diffinery Actions can't be changed over the API`.
32+Re-running a job replaces the check of the same name on the same commit. A job that a failed-job re-run keeps from the earlier attempt keeps its conclusion, and its check run points at the new attempt. Check runs from Actions can't be changed over the REST API: a `PATCH` fails with `check runs from Diffinery Actions can't be changed over the API`.
3333
3434 A job's `check_run_url` (in `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` and `GET /repos/{owner}/{repo}/actions/jobs/{job_id}`) is `https://diffinery.com/api/v3/repos/acme/website/check-runs/{job_id}`. `GET` on it returns the check run that job reports (its latest one, after a re-run), so tools that follow `check_run_url` from a job to its check run work as on GitHub.
3535
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
@@ -543,7 +543,7 @@
543543
544544 ### Actions
545545
546-- Workflows (list, get by path or file name, `POST /actions/workflows/{id}/dispatches`), runs (list, per workflow, get, attempts `GET /actions/runs/{id}/attempts/{n}`, cancel, `rerun` and `rerun-failed-jobs`), jobs (per run, get), logs (a zip per run, or text per job).
546+- Workflows (list, get by path or file name, `POST /actions/workflows/{id}/dispatches`), runs (list, per workflow, get, attempts `GET /actions/runs/{id}/attempts/{n}`, cancel, `rerun` and `rerun-failed-jobs`), jobs (per run, get, `POST /actions/jobs/{id}/rerun`), logs (a zip per run, or text per job).
547547 - **Artifacts:** `GET /actions/artifacts` (every artifact, newest first, `name` filter), `GET /actions/runs/{id}/artifacts` (one run; a run number covers all its attempts), `GET` and `DELETE /actions/artifacts/{id}`, and `GET /actions/artifacts/{id}/zip`, which redirects (`302`) to a signed URL valid for 10 minutes. Artifacts have numeric IDs, `digest`, `expires_at` and `expired`. An artifact past its retention (1 to 90 days, from `retention-days`) loses its content but stays listed with `expired: true` for 90 more days; downloading it gets `410 Artifact has expired`. Deleting needs write access and is audited as `actions.artifact.delete`.
548548 - **Caches:** `GET /actions/caches` (filter with `key` prefix and `ref`, sort by `last_accessed_at`, `created_at` or `size_in_bytes`), `DELETE /actions/caches?key=<key>[&ref=<ref>]` (returns the deleted entries), `DELETE /actions/caches/{id}`, and `GET /actions/cache/usage` (`active_caches_size_in_bytes`, `active_caches_count`). Each entry has its `ref` (like `refs/heads/main`), `key` and `version`. Deleting needs write access and is audited as `actions.cache.delete`. See [Caching and artifacts](/docs/actions/caching-and-artifacts) and [Limits](/docs/reference/limits#caches-and-artifacts).
549549 - **Workflow permissions:** `GET` and `PUT /actions/permissions/workflow` read and change `default_workflow_permissions` (`read` or `write`) and `can_approve_pull_request_reviews`. Admins only, people only; each change is audited as `repo.actions_workflow_settings`.
modifiedapps/diffinery/src/docs/api-meta.ts+23−2View file
@@ -1242,9 +1242,30 @@
12421242 },
12431243 "GET /repos/{owner}/{repo}/actions/runs/{id}/approvals": { summary: "List a run's deployment reviews", description: "Each reviewer's decision, with the environment and comment.", access: "read" },
12441244 "POST /repos/{owner}/{repo}/actions/runs/{id}/cancel": { summary: "Cancel a run", access: "write", status: 202 },
1245- "POST /repos/{owner}/{repo}/actions/runs/{id}/rerun": { summary: "Re-run a workflow run", description: "A workflow's `GITHUB_TOKEN` can't re-run workflows (403).", access: "write", status: 201 },
1246- "POST /repos/{owner}/{repo}/actions/runs/{id}/rerun-failed-jobs": { summary: "Re-run a run's failed jobs", description: "A workflow's `GITHUB_TOKEN` can't re-run workflows (403).", access: "write", status: 201 },
1245+ "POST /repos/{owner}/{repo}/actions/runs/{id}/rerun": {
1246+ summary: "Re-run a workflow run",
1247+ description: "Starts a new attempt that runs every job again. Answers `{ run_id }`, the new attempt's id. A workflow's `GITHUB_TOKEN` can't re-run workflows (403).",
1248+ access: "write",
1249+ status: 201,
1250+ body: { enable_debug_logging: ["boolean", "Accepted and ignored."] },
1251+ },
1252+ "POST /repos/{owner}/{repo}/actions/runs/{id}/rerun-failed-jobs": {
1253+ summary: "Re-run a run's failed jobs",
1254+ description:
1255+ "Starts a new attempt that runs the jobs and matrix legs that failed, were cancelled or timed out, and every job that depends on them. The others are kept from the earlier attempt as they were, with their logs and artifacts. Answers `{ run_id }`, the new attempt's id. 409 `this run has no failed jobs to re-run`, or when the run hasn't finished. A workflow's `GITHUB_TOKEN` can't re-run workflows (403).",
1256+ access: "write",
1257+ status: 201,
1258+ body: { enable_debug_logging: ["boolean", "Accepted and ignored."] },
1259+ },
12471260 "GET /repos/{owner}/{repo}/actions/jobs/{id}": { summary: "Get a job", description: "The job with its steps.", access: "read" },
1261+ "POST /repos/{owner}/{repo}/actions/jobs/{id}/rerun": {
1262+ summary: "Re-run a job",
1263+ description:
1264+ "Starts a new attempt of the job's run that runs this job (this matrix leg) and every job that depends on it; the other jobs are kept from the earlier attempt as they were. Answers `{ run_id }`, the new attempt's id. 409 when the run hasn't finished. A workflow's `GITHUB_TOKEN` can't re-run workflows (403).",
1265+ access: "write",
1266+ status: 201,
1267+ body: { enable_debug_logging: ["boolean", "Accepted and ignored."] },
1268+ },
12481269 "GET /repos/{owner}/{repo}/actions/jobs/{id}/logs": {
12491270 summary: "Get a job's logs",
12501271 description: "Plain text, one timestamped line each, or JSON with `format=json`.",
modifiedapps/diffinery/src/http/api.ts+8−2View file
@@ -1103,13 +1103,19 @@
11031103 await stub.cancelRun(c.req.param("id"), caller);
11041104 return c.json({}, 202);
11051105 });
1106-for (const path of ["rerun", "rerun-failed-jobs"]) {
1106+// GitHub answers `{}`; `run_id` is the new attempt's. The body's `enable_debug_logging` is accepted and ignored.
1107+for (const [path, scope] of [["rerun", "all"], ["rerun-failed-jobs", "failed"]] as const) {
11071108 api.post(`/repos/:owner/:repo/actions/runs/:id/${path}`, async (c) => {
11081109 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
1109- const r = await stub.rerun(c.req.param("id"), caller);
1110+ const r = await stub.rerun(c.req.param("id"), caller, scope);
11101111 return c.json({ run_id: r.id }, 201);
11111112 });
11121113 }
1114+api.post("/repos/:owner/:repo/actions/jobs/:id/rerun", async (c) => {
1115+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
1116+ const r = await stub.rerunJob(c.req.param("id"), caller);
1117+ return c.json({ run_id: r.id }, 201);
1118+});
11131119 api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => {
11141120 const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
11151121 const job = (await stub.job(c.req.param("id"))) as unknown as JobInfo;
modifiedapps/diffinery/src/mcp/server.ts+12−3View file
@@ -344,14 +344,23 @@
344344 },
345345 },
346346 rerun_workflow: {
347- description: "Re-run a workflow run.",
348- input: { ...OWNER_REPO, run_id: { type: "string" } },
347+ description: "Re-run a finished workflow run as a new attempt. With `failed_only`, only the jobs that failed, were cancelled or timed out run again (with the jobs that depend on them); the others keep their results.",
348+ input: { ...OWNER_REPO, run_id: { type: "string" }, failed_only: { type: "boolean" } },
349349 required: ["owner", "repo", "run_id"],
350350 run: async (c, a) => {
351351 const { stub, caller } = await repoOf(c, a, "write");
352- return stub.rerun(str(a, "run_id"), caller);
352+ return stub.rerun(str(a, "run_id"), caller, a.failed_only === true ? "failed" : "all");
353353 },
354354 },
355+ rerun_job: {
356+ description: "Re-run one job (one matrix leg) of a finished run, with the jobs that depend on it, as a new attempt of the run; the other jobs keep their results.",
357+ input: { ...OWNER_REPO, job_id: { type: "string" } },
358+ required: ["owner", "repo", "job_id"],
359+ run: async (c, a) => {
360+ const { stub, caller } = await repoOf(c, a, "write");
361+ return stub.rerunJob(str(a, "job_id"), caller);
362+ },
363+ },
355364 cancel_workflow: {
356365 description: "Cancel a queued or running workflow run.",
357366 input: { ...OWNER_REPO, run_id: { type: "string" } },
modifiedapps/diffinery/src/repo/RepoDO.ts+3−0View file
@@ -1183,6 +1183,9 @@
11831183 rerun(...a: Parameters<Actions["rerun"]>) {
11841184 return this.actions.rerun(...a);
11851185 }
1186+ rerunJob(...a: Parameters<Actions["rerunJob"]>) {
1187+ return this.actions.rerunJob(...a);
1188+ }
11861189 dispatchWorkflow(...a: Parameters<Actions["dispatch"]>) {
11871190 return this.actions.dispatch(...a);
11881191 }
modifiedapps/diffinery/src/repo/actions.ts+164−9View file
@@ -119,8 +119,33 @@
119119 agent_origin?: number;
120120 /** Account ids that started earlier attempts of this run (JSON list), for prevent_self_review. */
121121 prior_actors?: string | null;
122+ /** What a partial re-run keeps from the attempt it re-runs (JSON RerunCarry); null for every other run. */
123+ rerun_carry?: string | null;
122124 };
123125
126+/**
127+ * What a partial re-run (failed jobs, or one job) keeps from the attempt it re-runs, per job key:
128+ * `all` of the key's job rows, or the `legs` that aren't re-run, by their matrix (canonicalJson).
129+ * startJobKey copies them into the new attempt instead of running them. Keys not listed run as usual.
130+ */
131+export type RerunCarry = Record<string, { all: string[] } | { legs: Record<string, string[]> }>;
132+
133+/** Conclusions a partial re-run keeps; a job that ended any other way runs again with "re-run failed jobs". */
134+const KEPT_CONCLUSIONS = new Set(["success", "skipped", "neutral"]);
135+
136+/** JSON with object keys sorted, so two equal matrix combinations compare equal. */
137+export function canonicalJson(v: unknown): string {
138+ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
139+ if (v && typeof v === "object") {
140+ const o = v as Record<string, unknown>;
141+ return `{${Object.keys(o)
142+ .sort()
143+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
144+ .join(",")}}`;
145+ }
146+ return JSON.stringify(v) ?? "null";
147+}
148+
124149 export type JobRow = {
125150 id: string;
126151 run_id: string;
@@ -160,6 +185,8 @@
160185 gate_state: string | null;
161186 placement_tries: number;
162187 runner_info: string | null;
188+ /** Set on a job a partial re-run copied from an earlier attempt: the job that actually ran. */
189+ carried_from?: string | null;
163190 };
164191
165192 /** Where a job ran, as its runner reported it: the runner image, the container's location and instance. */
@@ -231,6 +258,8 @@
231258 /** The runner that took the job: a self-hosted runner's name, or the hosted container's name. */
232259 runnerName: string | null;
233260 runner: JobRunnerInfo | null;
261+ /** For a job a partial re-run kept from an earlier attempt (it didn't run again): the job that ran. */
262+ carriedFrom: string | null;
234263 /** Times a hosted job went back in line before a runner took it (no machine, or a runner on a replaced image). */
235264 placementTries: number;
236265 };
@@ -271,6 +300,7 @@
271300 summary: j.summary,
272301 runnerName: j.container?.replace(/^self-hosted:/, "") ?? null,
273302 runner: j.runner_info ? (JSON.parse(j.runner_info) as JobRunnerInfo) : null,
303+ carriedFrom: j.carried_from ?? null,
274304 placementTries: j.placement_tries ?? 0,
275305 });
276306
@@ -593,6 +623,8 @@
593623 agentOrigin?: boolean;
594624 /** Pull requests whose code the run includes (a merge group's). */
595625 prNumbers?: number[];
626+ /** A new attempt of an existing run: its number, attempt, earlier actors and what it keeps (rerun). */
627+ rerun?: { runNumber: number; attempt: number; priorActors: string[]; carry: RerunCarry | null };
596628 },
597629 ): Promise<RunInfo | null> {
598630 // Like GitHub, what a job does with its GITHUB_TOKEN (pushes, pull requests) starts no new
@@ -617,7 +649,7 @@
617649 const t = now();
618650 const run: RunRow = {
619651 id,
620- run_number: this.nextRunNumber(),
652+ run_number: o.rerun?.runNumber ?? this.nextRunNumber(),
621653 workflow_path: wf.path,
622654 workflow_name: wf.name ?? wf.path,
623655 event: o.event,
@@ -629,7 +661,7 @@
629661 created_at: t,
630662 updated_at: t,
631663 pr_number: o.prNumber ?? null,
632- attempt: 1,
664+ attempt: o.rerun?.attempt ?? 1,
633665 event_payload: JSON.stringify(completePayload({ publicUrl: this.env.PUBLIC_URL, meta: this.repo.meta, visibility: (o.payload.repository as { visibility?: string } | undefined)?.visibility === "public" ? "public" : "private" }, o.payload, o.caller)),
634666 inputs: JSON.stringify(o.inputs ?? {}),
635667 concurrency_group: null,
@@ -643,6 +675,8 @@
643675 })
644676 ? 1
645677 : 0,
678+ prior_actors: o.rerun ? JSON.stringify(o.rerun.priorActors) : null,
679+ rerun_carry: o.rerun?.carry ? JSON.stringify(o.rerun.carry) : null,
646680 };
647681 const ctx: ExprContext = { github: this.githubContext(run, "", ""), inputs: o.inputs ?? {}, vars: this.variables() };
648682 try {
@@ -652,7 +686,7 @@
652686 console.warn("run-name/concurrency evaluation failed", e);
653687 }
654688 this.sql.exec(
655- "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, pr_number, attempt, event_payload, inputs, concurrency_group, display_title, head_sha, agent_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
689+ "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, pr_number, attempt, event_payload, inputs, concurrency_group, display_title, head_sha, agent_origin, prior_actors, rerun_carry) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
656690 run.id,
657691 run.run_number,
658692 run.workflow_path,
@@ -672,6 +706,8 @@
672706 run.display_title,
673707 run.head_sha,
674708 run.agent_origin ?? 0,
709+ run.prior_actors ?? null,
710+ run.rerun_carry ?? null,
675711 );
676712 if (run.concurrency_group && wf.concurrency) {
677713 const cancelInProgress = evaluateValue(wf.concurrency.cancelInProgress, ctx);
@@ -750,6 +786,8 @@
750786
751787 async logs(jobId: string, afterSeq = -1): Promise<{ lines: Array<LogLine & { seq: number }>; complete: boolean }> {
752788 const j = this.jobRow(jobId);
789+ // A job a partial re-run kept from an earlier attempt shows the log of the job that ran.
790+ if (j.carried_from) jobId = j.carried_from;
753791 const rows = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 5000", jobId, afterSeq).toArray();
754792 if (rows.length === 0 && j.status === "completed") {
755793 const archived = await getFirst(this.env.BUCKET, logKeys(this.repo.meta.repoId, jobId));
@@ -919,6 +957,14 @@
919957 */
920958 async startJobKey(runId: string, jobKey: string): Promise<{ jobIds: string[] }> {
921959 const run = this.runRow(runId);
960+ // A partial re-run copies what it keeps from the earlier attempt: the whole job, or some of its matrix legs.
961+ const carry = run.rerun_carry ? (JSON.parse(run.rerun_carry) as RerunCarry)[jobKey] : undefined;
962+ if (carry && "all" in carry) {
963+ for (const id of carry.all) this.carryJob(run, id);
964+ this.repo.broadcast(`run:${runId}`, { action: "jobs" });
965+ return { jobIds: [] };
966+ }
967+ const kept = new Map(Object.entries(carry && "legs" in carry ? carry.legs : {}).map(([k, ids]) => [k, [...ids]]));
922968 const exp = await this.expanded(run);
923969 const job = exp.workflow.jobs[jobKey]!;
924970 const owner = exp.workflowOf.get(jobKey)!;
@@ -968,6 +1014,9 @@
9681014 const maxParallel = Number(evaluateValue(job.strategy?.maxParallel ?? combos.length, baseCtx)) || combos.length;
9691015 const ids: string[] = [];
9701016 combos.forEach((matrix, i) => {
1017+ // A leg the re-run keeps isn't evaluated again: it is the earlier attempt's, and counts for neither max-parallel nor minutes.
1018+ const keep = kept.get(canonicalJson(matrix))?.shift();
1019+ if (keep && this.carryJob(run, keep)) return;
9711020 // Like GitHub, name, runs-on, timeout-minutes, continue-on-error and concurrency are evaluated for each matrix leg.
9721021 const ctx = { ...baseCtx, matrix, strategy: { "fail-fast": failFast, "job-index": i, "job-total": combos.length, "max-parallel": maxParallel } };
9731022 let name = short;
@@ -1019,6 +1068,39 @@
10191068 return { jobIds: ids };
10201069 }
10211070
1071+ /**
1072+ * Copies a finished job of an earlier attempt into `run` unchanged, for a partial re-run: it
1073+ * doesn't run again, start a container or use minutes, and serves the earlier job's log. Its
1074+ * check run (if it had one) now points at the new attempt, with the same conclusion.
1075+ */
1076+ private carryJob(run: RunRow, oldId: string): boolean {
1077+ const old = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", oldId).toArray()[0];
1078+ if (!old || old.status !== "completed") return false;
1079+ const id = newId();
1080+ this.sql.exec(
1081+ `INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, matrix, strategy, started_at, completed_at, outputs, steps, failure, summary, container, timeout_minutes, labels, continue_on_error, concurrency_group, concurrency_cancel, log_chars, environment, runner_info, carried_from)
1082+ SELECT ?, ?, job_key, name, 'completed', conclusion, matrix, strategy, started_at, completed_at, outputs, steps, failure, summary, container, timeout_minutes, labels, continue_on_error, concurrency_group, concurrency_cancel, log_chars, environment, runner_info, COALESCE(carried_from, id) FROM jobs WHERE id = ?`,
1083+ id,
1084+ run.id,
1085+ oldId,
1086+ );
1087+ if (this.sql.exec("SELECT 1 FROM checks WHERE job_id = ? AND app = 'diffinery-actions'", oldId).toArray().length) {
1088+ const failure = old.failure ? (JSON.parse(old.failure) as { message: string }) : null;
1089+ this.repo.upsertCheck({
1090+ sha: run.head_sha ?? run.sha,
1091+ name: this.checkName(run, old),
1092+ status: "completed",
1093+ conclusion: old.conclusion,
1094+ runId: run.id,
1095+ jobId: id,
1096+ detailsUrl: this.jobUrl(run, id),
1097+ output: failure ? { title: "Failed", summary: failure.message } : { title: old.conclusion ?? "completed" },
1098+ });
1099+ }
1100+ this.repo.broadcast(`run:${run.id}`, { action: "job", jobId: id });
1101+ return true;
1102+ }
1103+
10221104 private checkName(run: RunRow, job: JobRow): string {
10231105 return `${run.workflow_name} / ${job.name}`;
10241106 }
@@ -1572,13 +1654,88 @@
15721654 await this.cancelInternal(runId, `Cancelled by ${caller.login}`);
15731655 }
15741656
1575- async rerun(runId: string, caller: Caller): Promise<RunInfo> {
1657+ /**
1658+ * A new attempt of a run, like GitHub's re-runs: `all` runs every job again; `failed` keeps the
1659+ * jobs and matrix legs that succeeded, were skipped or were neutral (unless they depend on a job
1660+ * that runs again) and runs the rest, with everything that depends on them.
1661+ */
1662+ async rerun(runId: string, caller: Caller, scope: "all" | "failed" = "all"): Promise<RunInfo> {
1663+ await this.mayRerun(caller);
1664+ const old = this.runRow(runId);
1665+ if (scope === "all") return this.newAttempt(old, caller, null);
1666+ if (old.status !== "completed") throw new HttpError(409, "this run hasn't finished yet; its failed jobs can be re-run once it has");
1667+ const jobs = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid", old.id).toArray();
1668+ const failed = jobs.filter((j) => !KEPT_CONCLUSIONS.has(j.conclusion ?? ""));
1669+ if (!failed.length) throw new HttpError(409, "this run has no failed jobs to re-run");
1670+ return this.newAttempt(old, caller, await this.carryFor(old, jobs, new Set(failed.map((j) => j.id))));
1671+ }
1672+
1673+ /** GitHub's "re-run a job": a new attempt that runs one job (one matrix leg) and every job that depends on it, keeping the rest. */
1674+ async rerunJob(jobId: string, caller: Caller): Promise<RunInfo> {
1675+ await this.mayRerun(caller);
1676+ const job = this.jobRow(jobId);
1677+ const old = this.runRow(job.run_id);
1678+ if (old.status !== "completed") throw new HttpError(409, "this run hasn't finished yet; its jobs can be re-run once it has");
1679+ const jobs = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid", old.id).toArray();
1680+ return this.newAttempt(old, caller, await this.carryFor(old, jobs, new Set([job.id])));
1681+ }
1682+
1683+ private async mayRerun(caller: Caller): Promise<void> {
15761684 if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied");
15771685 if (caller.id.startsWith("job:")) throw new HttpError(403, "a workflow's GITHUB_TOKEN cannot re-run workflows");
15781686 await this.refuseIfBlocked("run");
1579- const old = this.runRow(runId);
1687+ }
1688+
1689+ /**
1690+ * What a new attempt of `old` keeps when the jobs `rerun` (ids of its job rows) run again: every
1691+ * job that neither runs again nor depends (through `needs`, reusable workflow calls included) on
1692+ * one that does, and the other matrix legs of a job only some of whose legs run again. A job the
1693+ * earlier attempt never got to (the run was cancelled first) runs too. Null means run everything:
1694+ * the workflow can't be read any more, or what failed was the workflow's set-up.
1695+ */
1696+ private async carryFor(old: RunRow, jobs: JobRow[], rerun: Set<string>): Promise<RerunCarry | null> {
1697+ let exp: Expanded;
1698+ try {
1699+ exp = await this.expanded(old);
1700+ } catch {
1701+ return null;
1702+ }
1703+ const defs = exp.workflow.jobs;
1704+ if (jobs.some((j) => rerun.has(j.id) && !defs[j.job_key])) return null;
1705+ const rows = new Map<string, JobRow[]>();
1706+ for (const j of jobs) rows.set(j.job_key, [...(rows.get(j.job_key) ?? []), j]);
1707+ const seeded = (key: string) => !rows.get(key)?.length || rows.get(key)!.some((j) => rerun.has(j.id));
1708+ // The expanded workflow's `needs` already name a call's jobs, and what a called workflow's first jobs inherit from its caller.
1709+ const memo = new Map<string, boolean>();
1710+ const runsAgain = (key: string): boolean => {
1711+ const known = memo.get(key);
1712+ if (known !== undefined) return known;
1713+ memo.set(key, false);
1714+ const v = seeded(key) || upstream(key);
1715+ memo.set(key, v);
1716+ return v;
1717+ };
1718+ const upstream = (key: string): boolean => (defs[key]?.needs ?? []).some(runsAgain);
1719+ const carry: RerunCarry = {};
1720+ for (const key of Object.keys(defs)) {
1721+ if (upstream(key)) continue;
1722+ const mine = rows.get(key) ?? [];
1723+ if (!seeded(key)) {
1724+ carry[key] = { all: mine.map((j) => j.id) };
1725+ continue;
1726+ }
1727+ const legs: Record<string, string[]> = {};
1728+ for (const j of mine) if (!rerun.has(j.id) && j.status === "completed") (legs[canonicalJson(JSON.parse(j.matrix))] ??= []).push(j.id);
1729+ if (Object.keys(legs).length) carry[key] = { legs };
1730+ }
1731+ return carry;
1732+ }
1733+
1734+ private async newAttempt(old: RunRow, caller: Caller, carry: RerunCarry | null): Promise<RunInfo> {
15801735 const wf = (await this.loadWorkflows(old.sha)).find((w) => w.path === old.workflow_path)?.workflow;
15811736 if (!wf) throw new HttpError(422, "workflow file no longer valid at this commit");
1737+ // Whoever started an earlier attempt still can't approve this one where an environment prevents self-review.
1738+ const priorActors = [...new Set([...((old.prior_actors ? JSON.parse(old.prior_actors) : []) as string[]), (JSON.parse(old.actor) as Actor).id])];
15821739 const run = await this.createRun(wf, {
15831740 event: old.event,
15841741 ref: old.ref,
@@ -1591,13 +1748,11 @@
15911748 title: old.display_title,
15921749 // Re-running code an agent wrote keeps it acting as the agent, whoever re-runs it.
15931750 agentOrigin: !!old.agent_origin,
1751+ rerun: { runNumber: old.run_number, attempt: old.attempt + 1, priorActors, carry },
15941752 });
15951753 // Not started after all (Actions suspended meanwhile, or too many unfinished runs).
15961754 if (!run) throw new HttpError(409, "the run was not started; try again later");
1597- // Whoever started an earlier attempt still can't approve this one where an environment prevents self-review.
1598- const prior = [...new Set([...((old.prior_actors ? JSON.parse(old.prior_actors) : []) as string[]), (JSON.parse(old.actor) as Actor).id])];
1599- this.sql.exec("UPDATE runs SET attempt = ?, run_number = ?, prior_actors = ? WHERE id = ?", old.attempt + 1, old.run_number, JSON.stringify(prior), run.id);
1600- return toRun(this.runRow(run.id));
1755+ return run;
16011756 }
16021757
16031758 // ---------------------------------------------------------------- runner protocol
modifiedapps/diffinery/src/repo/runtime.ts+6−2View file
@@ -477,8 +477,12 @@
477477 */
478478 runArtifacts(runId: string, filter: { name?: string; id?: number; includeExpired?: boolean; legacy?: false } = {}): ArtifactEntry[] {
479479 const numeric = /^\d+$/.test(runId);
480- const where = [numeric ? "run_id IN (SELECT id FROM runs WHERE run_number = ?)" : "run_id = ?", ...(filter.includeExpired ? ["status IN ('ready', 'expired')"] : ["status = 'ready'", "expires_at > ?"])];
481- const args: unknown[] = [numeric ? Number(runId) : runId, ...(filter.includeExpired ? [] : [now()])];
480+ // An attempt also has the artifacts of the jobs a partial re-run kept from an earlier attempt, as on GitHub.
481+ const where = [
482+ numeric ? "run_id IN (SELECT id FROM runs WHERE run_number = ?)" : "(run_id = ? OR job_id IN (SELECT carried_from FROM jobs WHERE run_id = ? AND carried_from IS NOT NULL))",
483+ ...(filter.includeExpired ? ["status IN ('ready', 'expired')"] : ["status = 'ready'", "expires_at > ?"]),
484+ ];
485+ const args: unknown[] = [...(numeric ? [Number(runId)] : [runId, runId]), ...(filter.includeExpired ? [] : [now()])];
482486 if (filter.name !== undefined) {
483487 where.push("name = ?");
484488 args.push(filter.name);
modifiedapps/diffinery/src/repo/schema.ts+7−0View file
@@ -86,6 +86,8 @@
8686 created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expires_at TEXT NOT NULL
8787 );
8888 CREATE INDEX IF NOT EXISTS rt_artifacts_run ON rt_artifacts(run_id, name);
89+-- A partial re-run's attempt lists the artifacts of the jobs it kept by their job (repo/runtime.ts runArtifacts).
90+CREATE INDEX IF NOT EXISTS rt_artifacts_job ON rt_artifacts(job_id);
8991 -- What a job's GITHUB_TOKEN may do, from the workflow's permissions (repo/runtime.ts).
9092 CREATE TABLE IF NOT EXISTS job_grants (job_id TEXT PRIMARY KEY, role TEXT NOT NULL, permissions TEXT NOT NULL);
9193 -- GitHub-compatible extras (see ./releases.ts, ./statuses.ts, ./deployments.ts, ./social.ts).
@@ -224,6 +226,11 @@
224226 ["jobs", "gate_state", "TEXT"],
225227 // Logins that started earlier attempts of a re-run (JSON list): prevent_self_review covers them too.
226228 ["runs", "prior_actors", "TEXT"],
229+ // What a partial re-run (failed jobs, or one job) keeps from the attempt it re-runs (JSON RerunCarry in
230+ // repo/actions.ts): per job key, the earlier attempt's job rows copied into this one instead of running.
231+ ["runs", "rerun_carry", "TEXT"],
232+ // A job copied unchanged from an earlier attempt by a partial re-run: the job that actually ran, whose log it shows.
233+ ["jobs", "carried_from", "TEXT"],
227234 // Bytes an object takes as stored (compressed, in SQLite or R2); null in objects stored before it was kept.
228235 ["objects", "stored", "INTEGER"],
229236 ];
modifiedapps/diffinery/src/web/actions.tsx+31−3View file
@@ -21,6 +21,8 @@
2121
2222 const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
2323 const MAX_LOG_LINES = 20000;
24+/** Conclusions "Re-run failed jobs" keeps (repo/actions.ts KEPT_CONCLUSIONS); the button shows when a job ended any other way. */
25+const KEPT_ON_RERUN = new Set(["success", "skipped", "neutral"]);
2426
2527 const refLabel = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, "");
2628
@@ -381,6 +383,11 @@
381383 <button class="btn danger">{raw(ICONS.cancelled)} Cancel run</button>
382384 </Form>
383385 ) : null}
386+ {canWrite && run.status === "completed" && run.jobs.some((j) => !KEPT_ON_RERUN.has(j.conclusion ?? "")) ? (
387+ <Form action={`${base}/actions/runs/${run.id}/rerun-failed`} t={t} class="inline">
388+ <button class="btn">{raw(ICONS.history)} Re-run failed jobs</button>
389+ </Form>
390+ ) : null}
384391 {canWrite && run.status === "completed" ? (
385392 <Form action={`${base}/actions/runs/${run.id}/rerun`} t={t} class="inline">
386393 <button class="btn">{raw(ICONS.history)} Re-run all jobs</button>
@@ -448,6 +455,7 @@
448455 <b>{j.name}</b>
449456 {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null}
450457 {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null}
458+ {j.carriedFrom ? <div class="small muted">Kept from an earlier attempt</div> : null}
451459 </div>
452460 <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status === "approval" ? "waiting for approval" : j.status}</span>
453461 </a>
@@ -501,11 +509,24 @@
501509 return act(c, back, async () => l.stub.cancelRun(prm(c, "runId"), l.caller), "Cancellation requested.");
502510 });
503511
504- post(web, "/:owner/:repo/actions/runs/:runId/rerun", async (c) => {
512+ for (const [path, scope] of [["rerun", "all"], ["rerun-failed", "failed"]] as const) {
513+ post(web, `/:owner/:repo/actions/runs/:runId/${path}`, async (c) => {
514+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
515+ const base = repoUrl(l.repo);
516+ return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => {
517+ const run = await l.stub.rerun(prm(c, "runId"), l.caller, scope);
518+ return `${base}/actions/runs/${run.id}`;
519+ });
520+ });
521+ }
522+
523+ post(web, "/:owner/:repo/actions/runs/:runId/job/:jobId/rerun", async (c) => {
505524 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
506525 const base = repoUrl(l.repo);
507- return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => {
508- const run = await l.stub.rerun(prm(c, "runId"), l.caller);
526+ return act(c, `${base}/actions/runs/${prm(c, "runId")}/job/${prm(c, "jobId")}`, async () => {
527+ const job = (await l.stub.job(prm(c, "jobId"))) as unknown as JobInfo;
528+ if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found");
529+ const run = await l.stub.rerunJob(job.id, l.caller);
509530 return `${base}/actions/runs/${run.id}`;
510531 });
511532 });
@@ -528,6 +549,7 @@
528549 const lastSeq = logs.lines.length ? logs.lines[logs.lines.length - 1]!.seq : -1;
529550 const live = job.status !== "completed";
530551 const failedStep = job.failure?.step;
552+ const canRerun = run.status === "completed" && atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
531553 return render(
532554 c,
533555 { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id}` : undefined, liveMode: "log", lastSeq, wide: true },
@@ -565,9 +587,15 @@
565587 <a class="btn sm" href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>
566588 {raw(ICONS.download)} Raw logs
567589 </a>
590+ {canRerun ? (
591+ <Form action={`${base}/actions/runs/${run.id}/job/${job.id}/rerun`} t={csrfToken(c)} class="inline">
592+ <button class="btn sm">{raw(ICONS.history)} Re-run this job</button>
593+ </Form>
594+ ) : null}
568595 </div>
569596 </div>
570597 {matrixLabel(job.matrix) ? <p class="muted small">Matrix: {matrixLabel(job.matrix)}</p> : null}
598+ {job.carriedFrom ? <p class="muted small">Kept from an earlier attempt of this run: it didn't run again in attempt {run.attempt}.</p> : null}
571599 {job.failure ? (
572600 <div class="failure">
573601 <b>
modifiedapps/diffinery/test/e2e/e2e.test.mjs+80−0View file
@@ -643,6 +643,86 @@
643643 assert.equal(byName["lane blue"].conclusion, "success");
644644 });
645645
646+test("re-runs: failed jobs only keep what passed, and one job re-runs with what depends on it", async () => {
647+ const owner = (await ok("GET", "/user")).login;
648+ await ok("POST", "/user/repos", { name: "rerun", private: true });
649+ const src = join(work, "rerun");
650+ mkdirSync(join(src, ".github/workflows"), { recursive: true });
651+ writeFileSync(
652+ join(src, ".github/workflows/rerun.yml"),
653+ `name: Rerun
654+on: push
655+jobs:
656+ build:
657+ runs-on: [self-hosted, linux]
658+ steps:
659+ - run: echo "built in attempt \${{ github.run_attempt }}"
660+ test:
661+ needs: build
662+ strategy:
663+ matrix:
664+ n: [1, 2]
665+ runs-on: [self-hosted, linux]
666+ steps:
667+ - run: echo "leg \${{ matrix.n }}" && { test "\${{ matrix.n }}" = 1 || test "\${{ github.run_attempt }}" != 1; }
668+ report:
669+ needs: test
670+ runs-on: [self-hosted, linux]
671+ steps:
672+ - run: echo "report in attempt \${{ github.run_attempt }}"
673+`,
674+ );
675+ git(src, ["init", "-q", "-b", "main"]);
676+ git(src, ["add", "."]);
677+ git(src, ["commit", "-q", "-m", "rerun"]);
678+ git(src, ["remote", "add", "origin", `${base}/${owner}/rerun.git`]);
679+ git(src, ["push", "-q", "origin", "main"]);
680+ const R = `/repos/${owner}/rerun`;
681+ const finished = (id) =>
682+ until(`run ${id} to complete`, async () => {
683+ const r = await ok("GET", `${R}/actions/runs/${id}`);
684+ return r.status === "completed" ? r : null;
685+ });
686+ const jobsOf = async (id) => Object.fromEntries((await ok("GET", `${R}/actions/runs/${id}/jobs`)).jobs.map((j) => [j.name, j]));
687+ const logOf = async (id) => (await fetch(`${base}/api/v3${R}/actions/jobs/${id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
688+
689+ // Attempt 1: leg 2 fails, so report is skipped.
690+ const first = await until("the first run", async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs[0]);
691+ const claimed = [];
692+ for (let i = 0; i < 3; i++) claimed.push((await runOneJob(owner, "rerun", { allowFailure: true })).claim.name);
693+ assert.deepEqual(claimed, ["build", "test (1)", "test (2)"]);
694+ assert.equal((await finished(first.id)).conclusion, "failure");
695+ assert.equal((await jobsOf(first.id)).report.conclusion, "skipped");
696+
697+ // Re-run failed jobs: build and leg 1 are kept as they were; leg 2 and report run again.
698+ const second = (await api("POST", `${R}/actions/runs/${first.id}/rerun-failed-jobs`, { enable_debug_logging: false })).json.run_id;
699+ assert.ok(second);
700+ const again = [];
701+ for (let i = 0; i < 2; i++) again.push((await runOneJob(owner, "rerun")).claim.name);
702+ assert.deepEqual(again, ["test (2)", "report"]);
703+ const done = await finished(second);
704+ assert.deepEqual([done.conclusion, done.run_attempt, done.run_number], ["success", 2, first.run_number]);
705+ const kept = await jobsOf(second);
706+ const before = await jobsOf(first.id);
707+ assert.equal(kept.build.carriedFrom, before.build.id);
708+ assert.equal(kept["test (1)"].carriedFrom, before["test (1)"].id);
709+ assert.equal(kept["test (2)"].carriedFrom, null);
710+ assert.match(await logOf(kept.build.id), /built in attempt 1/);
711+ assert.match(await logOf(kept.report.id), /report in attempt 2/);
712+ assert.equal((await runnerClaim(R, ["linux"])).status, 204, "nothing else waits for a runner");
713+
714+ // Nothing failed any more; re-running one job runs it and what depends on it (nothing here), keeping the rest.
715+ const none = await api("POST", `${R}/actions/runs/${second}/rerun-failed-jobs`);
716+ assert.deepEqual([none.status, none.json.message], [409, "this run has no failed jobs to re-run"]);
717+ const third = (await ok("POST", `${R}/actions/jobs/${kept.report.id}/rerun`)).run_id;
718+ assert.equal((await runOneJob(owner, "rerun")).claim.name, "report");
719+ assert.equal((await finished(third)).conclusion, "success");
720+ const last = await jobsOf(third);
721+ assert.equal(last.build.carriedFrom, before.build.id, "a job kept twice points at the job that ran");
722+ assert.equal(last["test (2)"].carriedFrom, kept["test (2)"].id);
723+ assert.equal(last.report.carriedFrom, null);
724+});
725+
646726 test("workflow_run: a workflow follows another one on the default branch", async () => {
647727 const owner = (await ok("GET", "/user")).login;
648728 await ok("POST", "/user/repos", { name: "chain" });
addedapps/diffinery/test/unit/rerun.test.ts+329−0View file
@@ -1,0 +1,329 @@
1+import { describe, expect, it } from "vitest";
2+import { DatabaseSync } from "node:sqlite";
3+import { parseWorkflow, type Workflow } from "@diffinery/workflow-yaml";
4+import { Actions, canonicalJson } from "../../src/repo/actions.js";
5+import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
6+import { logKeys } from "../../src/lib/storage-keys.js";
7+import { Runtime } from "../../src/repo/runtime.js";
8+import { testD1 } from "./stubs/d1.js";
9+import type { RepoDO } from "../../src/repo/RepoDO.js";
10+import type { Env } from "../../src/env.js";
11+import type { Caller } from "../../src/repo/types.js";
12+
13+/** The Durable Object's SqlStorage over node:sqlite: exec(query, ...bindings) with toArray() and one(). */
14+function sqlStorage() {
15+ const db = new DatabaseSync(":memory:");
16+ return {
17+ exec(query: string, ...args: unknown[]) {
18+ const params = args.map((a) => (a === undefined ? null : a)) as Array<string | number | null>;
19+ let rows: unknown[] = [];
20+ if (!params.length && query.trim().replace(/;\s*$/, "").includes(";")) db.exec(query);
21+ else rows = db.prepare(query).all(...params);
22+ return {
23+ toArray: () => rows as never[],
24+ one: () => {
25+ if (rows.length !== 1) throw new Error(`expected one row, got ${rows.length}`);
26+ return rows[0] as never;
27+ },
28+ };
29+ },
30+ };
31+}
32+
33+const parse = (yaml: string, path: string): Workflow => {
34+ const { workflow, errors } = parseWorkflow(yaml, path);
35+ if (!workflow) throw new Error(errors.map((e) => e.message).join("; "));
36+ return workflow;
37+};
38+
39+const WORKFLOW = `name: w
40+on: push
41+jobs:
42+ lint:
43+ runs-on: ubuntu-latest
44+ steps: [{ run: echo lint }]
45+ test:
46+ runs-on: ubuntu-latest
47+ strategy:
48+ matrix:
49+ os: [a, b, c]
50+ steps: [{ run: echo test }]
51+ deploy:
52+ needs: test
53+ runs-on: ubuntu-latest
54+ steps: [{ run: echo deploy }]
55+ report:
56+ needs: lint
57+ runs-on: ubuntu-latest
58+ steps: [{ run: echo report }]
59+`;
60+
61+type Row = { id: string; run_id: string; job_key: string; name: string; status: string; conclusion: string | null; matrix: string; carried_from: string | null; container: string | null };
62+type Outcome = (job: Row) => "success" | "failure";
63+
64+const ADA: Caller = { id: "u1", login: "ada", kind: "user", role: "write" };
65+
66+/**
67+ * Actions over an in-memory repository and D1, with the workflow files given, and a stand-in for
68+ * RunWorkflow (`drive`) that walks the job graph and finishes each started job as `outcome` says.
69+ */
70+function fixture(files: Record<string, string>) {
71+ const sql = sqlStorage();
72+ sql.exec(REPO_SCHEMA);
73+ migrateRepoSchema(sql as unknown as SqlStorage);
74+ const DB = testD1();
75+ DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, created_at) VALUES ('acme', 'acme', 'org', 'active', ?)").run(new Date().toISOString());
76+ const objects = new Map<string, string>();
77+ const started: string[] = [];
78+ const checks: Array<{ name: string; status: string; conclusion?: string | null; runId?: string; jobId?: string }> = [];
79+ const repo = {
80+ sql,
81+ meta: { repoId: "r", owner: "acme", ownerId: "acme", ownerKind: "org", name: "app", defaultBranch: "main" },
82+ headRef: "refs/heads/main",
83+ getRef: () => null,
84+ // Like Collab.upsertCheck: one check run per commit and name.
85+ upsertCheck: (c: (typeof checks)[number] & { sha: string }) => {
86+ checks.push(c);
87+ sql.exec("DELETE FROM checks WHERE sha = ? AND name = ?", c.sha, c.name);
88+ sql.exec("INSERT INTO checks (id, sha, name, status, conclusion, run_id, job_id) VALUES (?, ?, ?, ?, ?, ?, ?)", `${c.sha}/${c.name}`, c.sha, c.name, c.status, c.conclusion ?? null, c.runId ?? null, c.jobId ?? null);
89+ },
90+ broadcast: () => {},
91+ emit: () => {},
92+ deploys: { jobCompleted: () => {}, protection: () => null },
93+ runtime: { sweepJob: async () => {} },
94+ policy: { agentOrigin: () => false },
95+ } as unknown as RepoDO;
96+ const env = {
97+ DB,
98+ PUBLIC_URL: "https://d.test",
99+ BUCKET: {
100+ put: async (k: string, v: string) => void objects.set(k, v),
101+ get: async (k: string) => (objects.has(k) ? { json: async () => JSON.parse(objects.get(k)!) } : null),
102+ },
103+ RUNNER: { idFromName: (n: string) => n, get: (n: string) => ({ startJob: async () => void started.push(n), stopJob: async () => {} }) },
104+ RUN_WORKFLOW: { create: async () => {}, get: async () => ({ sendEvent: async () => {} }) },
105+ } as unknown as Env;
106+ const actions = new Actions(repo, env);
107+ const workflows = Object.entries(files).map(([path, text]) => ({ path, workflow: parse(text, path), errors: [] as string[] }));
108+ Object.defineProperty(actions, "loadWorkflows", { value: async () => workflows });
109+ Object.defineProperty(actions, "loadCalled", { value: async (uses: string) => ({ workflow: workflows.find((w) => w.path === uses.replace(/^\.\//, ""))!.workflow, source: { repoId: null, sha: "abc" } }) });
110+ const internal = actions as unknown as { completeJob(id: string, c: unknown): Promise<void> };
111+ const jobs = (runId: string) => sql.exec("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid", runId).toArray() as Row[];
112+ const leg = (j: Row) => `${j.job_key}${j.matrix === "{}" ? "" : ` ${Object.values(JSON.parse(j.matrix)).join(",")}`}`;
113+ /** Each job (by `leg`) with its conclusion, and whether it was kept from an earlier attempt. */
114+ const summary = (runId: string) => Object.fromEntries(jobs(runId).map((j) => [leg(j), `${j.conclusion}${j.carried_from ? " (kept)" : ""}`]));
115+
116+ async function drive(runId: string, outcome: Outcome) {
117+ const plan = await actions.plan(runId);
118+ expect(plan.error).toBeUndefined();
119+ const done = new Set<string>();
120+ while (done.size < plan.jobs.length) {
121+ const next = plan.jobs.find((n) => !done.has(n.key) && n.needs.every((k) => done.has(k)))!;
122+ await actions.startJobKey(runId, next.key);
123+ // Finish the key's started legs one at a time, in order, as their runners would.
124+ for (;;) {
125+ const running = jobs(runId).find((j) => j.job_key === next.key && j.status === "waiting");
126+ if (!running) break;
127+ await internal.completeJob(running.id, { conclusion: outcome(running), outputs: {} });
128+ }
129+ expect(jobs(runId).filter((j) => j.job_key === next.key && j.status !== "completed")).toEqual([]);
130+ done.add(next.key);
131+ }
132+ return actions.finishRun(runId);
133+ }
134+
135+ async function firstRun(outcome: Outcome) {
136+ const run = (await actions.createRun(workflows[0]!.workflow, { event: "push", ref: "refs/heads/main", sha: "abc", payload: {}, caller: ADA, title: "first" }))!;
137+ await drive(run.id, outcome);
138+ return run.id;
139+ }
140+ return { sql, actions, started, checks, objects, jobs, leg, summary, drive, firstRun };
141+}
142+
143+const failB: Outcome = (j) => (j.job_key === "test" && JSON.parse(j.matrix).os === "b" ? "failure" : "success");
144+const allPass: Outcome = () => "success";
145+
146+describe("re-running failed jobs", () => {
147+ it("keeps the legs that passed and re-runs the failed and cancelled ones, with what depends on them", async () => {
148+ const f = fixture({ ".github/workflows/w.yml": WORKFLOW });
149+ const first = await f.firstRun(failB);
150+ // fail-fast: b's failure cancelled c, and deploy (needs test) was skipped.
151+ expect(f.summary(first)).toEqual({ lint: "success", "test a": "success", "test b": "failure", "test c": "cancelled", deploy: "skipped", report: "success" });
152+ expect(f.actions.getRun(first).conclusion).toBe("failure");
153+
154+ const before = f.started.length;
155+ const second = await f.actions.rerun(first, ADA, "failed");
156+ expect(second).toMatchObject({ attempt: 2, number: f.actions.getRun(first).number });
157+ const done = await f.drive(second.id, allPass);
158+ expect(done.conclusion).toBe("success");
159+ expect(f.summary(second.id)).toEqual({ lint: "success (kept)", report: "success (kept)", "test a": "success (kept)", "test b": "success", "test c": "success", deploy: "success" });
160+ // Only the re-run legs started a container.
161+ const ran = f.jobs(second.id).filter((j) => !j.carried_from);
162+ expect(f.started.slice(before).sort()).toEqual(ran.map((j) => `job-${j.id}`).sort());
163+ // A kept job is a copy of the one that ran, pointing at it.
164+ const old = Object.fromEntries(f.jobs(first).map((j) => [f.leg(j), j]));
165+ for (const j of f.jobs(second.id).filter((x) => x.carried_from)) {
166+ expect(j.carried_from).toBe(old[f.leg(j)]!.id);
167+ expect(j.name).toBe(old[f.leg(j)]!.name);
168+ }
169+ // Kept jobs use no minutes.
170+ const usage = (await (f.actions as unknown as { env: Env }).env.DB.prepare("SELECT id FROM compute_usage").all<{ id: string }>()).results.map((r) => r.id);
171+ for (const j of f.jobs(second.id)) expect(usage.includes(j.id)).toBe(!j.carried_from);
172+ // The kept jobs' check runs now point at the new attempt, completed with their conclusion.
173+ const lintCheck = f.checks.filter((c) => c.name === "w / lint").at(-1)!;
174+ expect(lintCheck).toMatchObject({ status: "completed", conclusion: "success", runId: second.id });
175+ });
176+
177+ it("refuses a run without failed jobs, and one that hasn't finished", async () => {
178+ const f = fixture({ ".github/workflows/w.yml": WORKFLOW });
179+ const first = await f.firstRun(allPass);
180+ await expect(f.actions.rerun(first, ADA, "failed")).rejects.toMatchObject({ status: 409, detail: "this run has no failed jobs to re-run" });
181+ const second = await f.actions.rerun(first, ADA);
182+ await expect(f.actions.rerun(second.id, ADA, "failed")).rejects.toMatchObject({ status: 409 });
183+ await expect(f.actions.rerunJob(f.jobs(first)[0]!.id, ADA)).resolves.toMatchObject({ attempt: 2 });
184+ });
185+
186+ it("checks permissions like a full re-run", async () => {
187+ const f = fixture({ ".github/workflows/w.yml": WORKFLOW });
188+ const first = await f.firstRun(failB);
189+ await expect(f.actions.rerun(first, { ...ADA, role: "read" }, "failed")).rejects.toMatchObject({ status: 403 });
190+ await expect(f.actions.rerun(first, { ...ADA, id: "job:x" }, "failed")).rejects.toMatchObject({ status: 403, detail: "a workflow's GITHUB_TOKEN cannot re-run workflows" });
191+ await expect(f.actions.rerunJob(f.jobs(first)[0]!.id, { ...ADA, role: "read" })).rejects.toMatchObject({ status: 403 });
192+ });
193+
194+ it("re-runs a full run as before", async () => {
195+ const f = fixture({ ".github/workflows/w.yml": WORKFLOW });
196+ const first = await f.firstRun(failB);
197+ const second = await f.actions.rerun(first, ADA);
198+ await f.drive(second.id, allPass);
199+ expect(Object.values(f.summary(second.id))).toEqual(Array(6).fill("success"));
200+ expect((f.sql.exec("SELECT rerun_carry FROM runs WHERE id = ?", second.id).one() as { rerun_carry: string | null }).rerun_carry).toBeNull();
201+ });
202+
203+ it("starts up to max-parallel re-run legs; kept legs don't count", async () => {
204+ const f = fixture({
205+ ".github/workflows/w.yml": `name: w
206+on: push
207+jobs:
208+ test:
209+ runs-on: ubuntu-latest
210+ strategy:
211+ fail-fast: false
212+ max-parallel: 1
213+ matrix:
214+ n: [1, 2, 3, 4]
215+ steps: [{ run: echo }]
216+`,
217+ });
218+ const first = await f.firstRun((j) => (JSON.parse(j.matrix).n <= 2 ? "failure" : "success"));
219+ const second = await f.actions.rerun(first, ADA, "failed");
220+ await f.actions.plan(second.id);
221+ await f.actions.startJobKey(second.id, "test");
222+ expect(f.jobs(second.id).map((j) => [f.leg(j), j.status, !!j.carried_from])).toEqual([
223+ ["test 1", "waiting", false],
224+ ["test 2", "queued", false],
225+ ["test 3", "completed", true],
226+ ["test 4", "completed", true],
227+ ]);
228+ });
229+});
230+
231+describe("re-running one job", () => {
232+ it("re-runs that job and its dependents and keeps the rest, failures included", async () => {
233+ const f = fixture({ ".github/workflows/w.yml": WORKFLOW });
234+ const first = await f.firstRun(failB);
235+ const lint = f.jobs(first).find((j) => j.job_key === "lint")!;
236+ const second = await f.actions.rerunJob(lint.id, ADA);
237+ await f.drive(second.id, allPass);
238+ expect(f.summary(second.id)).toEqual({ lint: "success", report: "success", "test a": "success (kept)", "test b": "failure (kept)", "test c": "cancelled (kept)", deploy: "skipped (kept)" });
239+ expect(f.actions.getRun(second.id).conclusion).toBe("failure");
240+
241+ // One matrix leg: the other legs are kept. deploy needs test, so it runs again, and is skipped: test c is still cancelled.
242+ const b = f.jobs(second.id).find((j) => f.leg(j) === "test b")!;
243+ const third = await f.actions.rerunJob(b.id, ADA);
244+ expect(third.attempt).toBe(3);
245+ await f.drive(third.id, allPass);
246+ expect(f.summary(third.id)).toEqual({ lint: "success (kept)", report: "success (kept)", "test a": "success (kept)", "test b": "success", "test c": "cancelled (kept)", deploy: "skipped" });
247+ // A job kept twice points at the job that actually ran, in the first attempt.
248+ const a1 = f.jobs(first).find((j) => f.leg(j) === "test a")!;
249+ expect(f.jobs(third.id).find((j) => f.leg(j) === "test a")!.carried_from).toBe(a1.id);
250+ });
251+
252+ it("serves a kept job's log from the job that ran", async () => {
253+ const f = fixture({ ".github/workflows/w.yml": WORKFLOW });
254+ const first = await f.firstRun((j) => {
255+ if (j.job_key === "lint") f.sql.exec("INSERT INTO logs (job_id, seq, t, step, text) VALUES (?, 0, 't', 0, 'linted')", j.id);
256+ return failB(j);
257+ });
258+ const lint = f.jobs(first).find((j) => j.job_key === "lint")!;
259+ expect(f.objects.has(logKeys("r", lint.id)[0]!)).toBe(true);
260+ const second = await f.actions.rerun(first, ADA, "failed");
261+ await f.drive(second.id, allPass);
262+ const kept = f.jobs(second.id).find((j) => j.job_key === "lint")!;
263+ expect(kept.carried_from).toBe(lint.id);
264+ const logs = await f.actions.logs(kept.id);
265+ expect(logs).toMatchObject({ complete: true, lines: [{ text: "linted" }] });
266+ expect(f.actions.getJob(kept.id).carriedFrom).toBe(lint.id);
267+ });
268+
269+ it("lists the kept jobs' artifacts with the new attempt's", async () => {
270+ const f = fixture({ ".github/workflows/w.yml": WORKFLOW });
271+ const first = await f.firstRun(failB);
272+ const byLeg = Object.fromEntries(f.jobs(first).map((j) => [f.leg(j), j.id]));
273+ const t = new Date().toISOString();
274+ const later = new Date(Date.now() + 86400_000).toISOString();
275+ const add = (runId: string, jobId: string, name: string) =>
276+ f.sql.exec("INSERT INTO rt_artifacts (run_id, job_id, name, size, status, created_at, updated_at, expires_at) VALUES (?, ?, ?, 1, 'ready', ?, ?, ?)", runId, jobId, name, t, t, later);
277+ add(first, byLeg.lint!, "lint-report");
278+ add(first, byLeg["test b"]!, "failed-b");
279+ const second = await f.actions.rerun(first, ADA, "failed");
280+ await f.drive(second.id, allPass);
281+ add(second.id, f.jobs(second.id).find((j) => f.leg(j) === "test b")!.id, "passed-b");
282+ const rt = new Runtime({ sql: f.sql } as unknown as RepoDO, {} as Env);
283+ // The failed leg ran again, so its first attempt's artifact isn't the new attempt's.
284+ expect(rt.runArtifacts(second.id).map((a) => a.name).sort()).toEqual(["lint-report", "passed-b"]);
285+ expect(rt.runArtifacts(first).map((a) => a.name).sort()).toEqual(["failed-b", "lint-report"]);
286+ });
287+});
288+
289+describe("re-running failed jobs of a reusable workflow call", () => {
290+ it("treats the call's jobs like any other and re-runs what needs the call", async () => {
291+ const f = fixture({
292+ ".github/workflows/w.yml": `name: w
293+on: push
294+jobs:
295+ build:
296+ uses: ./.github/workflows/lib.yml
297+ ship:
298+ needs: build
299+ runs-on: ubuntu-latest
300+ steps: [{ run: echo ship }]
301+ docs:
302+ runs-on: ubuntu-latest
303+ steps: [{ run: echo docs }]
304+`,
305+ ".github/workflows/lib.yml": `on: workflow_call
306+jobs:
307+ compile:
308+ runs-on: ubuntu-latest
309+ steps: [{ run: echo compile }]
310+ check:
311+ needs: compile
312+ runs-on: ubuntu-latest
313+ steps: [{ run: echo check }]
314+`,
315+ });
316+ const first = await f.firstRun((j) => (j.job_key === "build/check" ? "failure" : "success"));
317+ expect(f.summary(first)).toEqual({ "build/compile": "success", "build/check": "failure", ship: "skipped", docs: "success" });
318+ const second = await f.actions.rerun(first, ADA, "failed");
319+ await f.drive(second.id, allPass);
320+ expect(f.summary(second.id)).toEqual({ "build/compile": "success (kept)", "build/check": "success", ship: "success", docs: "success (kept)" });
321+ });
322+});
323+
324+describe("canonicalJson", () => {
325+ it("doesn't depend on key order", () => {
326+ expect(canonicalJson({ b: 1, a: { d: [1, { y: 2, x: 1 }], c: null } })).toBe(canonicalJson({ a: { c: null, d: [1, { x: 1, y: 2 }] }, b: 1 }));
327+ expect(canonicalJson({ a: 1 })).not.toBe(canonicalJson({ a: "1" }));
328+ });
329+});