Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24
A finished run publishes workflow.completed, for webhooks and automations, and starts workflows that follow it with workflow_run (filtered by workflows:, never chained). A run's artifacts are listed on its page with a download link. The runner image moves to Node 24, the runtime current actions declare, and examples use checkout@v7, setup-node@v7 and Node 24. Docs cover artifacts, the cache, workflow_run and when an agent's pull request runs its workflows.
22 files+214−210/22 viewed
| 29 | 29 | ||
| 30 | 30 | | On GitHub | On g1t | | |
| 31 | 31 | | --- | --- | | |
| 32 | − | | `on:` `push` (branches, tags, paths), `pull_request`, `pull_request_target`, `issues`, `issue_comment`, `pull_request_review`, `schedule`, `workflow_dispatch` | The same, from g1t's own pushes, pull requests, issues and comments. | | |
| 32 | + | | `on:` `push` (branches, tags, paths), `pull_request`, `pull_request_target`, `issues`, `issue_comment`, `pull_request_review`, `schedule`, `workflow_dispatch`, `workflow_run` | The same, from g1t's own pushes, pull requests, issues and comments. | | |
| 33 | 33 | | `jobs`, `needs`, `if`, `outputs`, `env`, `defaults`, `timeout-minutes`, `continue-on-error` | The same. | | |
| 34 | 34 | | `strategy.matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`, a matrix from `fromJSON(needs.…)` | The same. | | |
| 35 | 35 | | `concurrency` with `cancel-in-progress` | The same. | | |
| 36 | 36 | | `${{ }}` expressions: every operator, function and context | The same, including `hashFiles`, `success()`, `failure()`, `always()` and `cancelled()`. | | |
| 37 | 37 | | `run:` with `bash`, `sh`, `python` or a custom shell | The same. | | |
| 38 | − | | JavaScript actions (`uses: owner/repo@v4`) | Fetched from GitHub and run as they are, with Node 22. | | |
| 38 | + | | JavaScript actions (`uses: owner/repo@v7`) | Fetched from GitHub and run as they are, on Node 24, the runtime current actions declare. | | |
| 39 | 39 | | Composite actions | The same. | | |
| 40 | 40 | | `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. | | |
| 41 | 41 | | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. | | |
| 42 | 42 | | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. | | |
| 43 | 43 | | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same; `GITHUB_TOKEN` is a token for g1t. | | |
| 44 | + | | `actions/upload-artifact`, `actions/download-artifact` | Kept with the run for 14 days, passed between its jobs, and downloadable from the run's page. Up to 60 MB each. | | |
| 45 | + | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository for 7 days, found by `key` or the newest under a `restore-keys` prefix. Up to 60 MB each. | | |
| 44 | 46 | ||
| 45 | 47 | The **Actions** page of a workflow says, under *How this runs on g1t*, | |
| 46 | 48 | anything in it that runs differently. | |
| 51 | 53 | `runs-on: windows-latest` or `macos-latest` fails, and says so. | |
| 52 | 54 | - **Docker** container actions, `services:` containers and `container:`. | |
| 53 | 55 | - **Reusable workflows** (`uses:` on a job). | |
| 54 | − | - **Artifacts and the cache.** `actions/upload-artifact` does nothing and | |
| 55 | − | says so; `download-artifact` fails. `actions/cache` always misses. | |
| 56 | + | - **The toolkit's own cache.** Actions that cache through GitHub's service | |
| 57 | + | themselves, such as `actions/setup-node` with `cache: npm`, run without | |
| 58 | + | it. Use `actions/cache` for the same effect. | |
| 56 | 59 | - **Environments' protection rules**. A job with `environment:` runs with | |
| 57 | 60 | the repository's secrets. | |
| 58 | 61 | ||
| 59 | 62 | ## The runner | |
| 60 | 63 | ||
| 61 | − | Jobs run in a fresh sandbox each: Debian with Node 22, Python 3, Go, Rust, | |
| 64 | + | Jobs run in a fresh sandbox each: Debian with Node 24, Python 3, Go, Rust, | |
| 62 | 65 | `build-essential`, `git`, `curl`, `jq` and passwordless `sudo`, in GitHub's | |
| 63 | 66 | layout (`/home/runner/work`, `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`). | |
| 64 | 67 | `runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04`, `self-hosted` and | |
| 81 | 84 | ||
| 82 | 85 | ## Pull requests | |
| 83 | 86 | ||
| 87 | + | A pull request's workflows run on each new head: when it is opened, when | |
| 88 | + | a commit is pushed to it, and, for one a g1t agent makes, when the agent | |
| 89 | + | marks it ready, which on g1t is when it first has code. Each head runs | |
| 90 | + | each workflow once. | |
| 91 | + | ||
| 84 | 92 | A run on a pull request's latest commit is a check on it: | |
| 85 | 93 | ||
| 86 | 94 | - While a workflow runs, the pull request waits for it before merging. |
| 61 | 61 | | `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. | | |
| 62 | 62 | | `checks.completed` | An issue's acceptance checks finished on a pull request. | | |
| 63 | 63 | | `review.completed` | A g1t agent reviewed a pull request. | | |
| 64 | + | | `workflow.completed` | A GitHub Actions run finished: `if: { conclusion: failure }` to act on failures. | | |
| 64 | 65 | | `queue.changed` | The merge queue changed. | | |
| 65 | 66 | ||
| 66 | 67 | A schedule has five fields: minute, hour, day of the month, month and day |
| 67 | 67 | | `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. `data.number`, `data.issue`; on merge, `data.commit`. | | |
| 68 | 68 | | `checks.completed` | An issue's acceptance checks finished on a pull request. `data.status` is `passed`, `failed` or `errored`. | | |
| 69 | 69 | | `review.completed` | A g1t agent reviewed a pull request. `data.verdict`. | | |
| 70 | + | | `workflow.completed` | A GitHub Actions run finished. `data.workflow`, `data.conclusion`, `data.runId`, `data.sha`, `data.pull`. | | |
| 70 | 71 | | `queue.changed` | The merge queue gained, lost or settled an entry. | | |
| 71 | 72 | | `session.appended` | An agent's session grew. Busy: choose it only if you need it. | | |
| 72 | 73 |
| 22 | 22 | title: "Pull requests", | |
| 23 | 23 | events: ["pull.opened", "pull.ready", "pull.updated", "pull.merge_requested", "pull.merged", "pull.closed"], | |
| 24 | 24 | }, | |
| 25 | − | { title: "Checks, reviews and the queue", events: ["checks.completed", "review.completed", "queue.changed"] }, | |
| 25 | + | { title: "Checks, reviews and the queue", events: ["checks.completed", "workflow.completed", "review.completed", "queue.changed"] }, | |
| 26 | 26 | { title: "Agents", events: ["session.appended"] }, | |
| 27 | 27 | ]; | |
| 28 | 28 |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * A run's artifacts, as the API keeps them in KV: a metadata key per | |
| 5 | + | * artifact, `a/{run}/{name}`, and its bytes in chunks, `…#0`, `…#1`. | |
| 6 | + | */ | |
| 7 | + | export type ArtifactMeta = { name: string; size: number; at: number }; | |
| 8 | + | ||
| 9 | + | type Meta = { size: number; chunks: number; at: number; name: string }; | |
| 10 | + | ||
| 11 | + | export async function listArtifacts(run: string): Promise<ArtifactMeta[]> { | |
| 12 | + | const listed = await env.BLOBS.list<Meta>({ prefix: `a/${run}/` }); | |
| 13 | + | return listed.keys | |
| 14 | + | .filter((key) => !key.name.includes("#") && key.metadata) | |
| 15 | + | .map((key) => ({ name: key.metadata!.name, size: key.metadata!.size, at: key.metadata!.at })) | |
| 16 | + | .sort((a, b) => a.name.localeCompare(b.name)); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function readArtifact(run: string, name: string): Promise<Uint8Array | null> { | |
| 20 | + | const base = `a/${run}/${name}`; | |
| 21 | + | const meta = await env.BLOBS.get<Meta>(base, "json"); | |
| 22 | + | if (!meta) return null; | |
| 23 | + | const parts = await Promise.all( | |
| 24 | + | Array.from({ length: meta.chunks }, (_, index) => env.BLOBS.get(`${base}#${index}`, "arrayBuffer")), | |
| 25 | + | ); | |
| 26 | + | if (parts.some((part) => part == null)) return null; | |
| 27 | + | const out = new Uint8Array(meta.size); | |
| 28 | + | let offset = 0; | |
| 29 | + | for (const part of parts as ArrayBuffer[]) { | |
| 30 | + | out.set(new Uint8Array(part), offset); | |
| 31 | + | offset += part.byteLength; | |
| 32 | + | } | |
| 33 | + | return out; | |
| 34 | + | } |
| 44 | 44 | route("queue", "routes/repo/queue.tsx"), | |
| 45 | 45 | route("actions", "routes/repo/actions.tsx"), | |
| 46 | 46 | route("actions/runs/:id", "routes/repo/actions-run.tsx"), | |
| 47 | + | route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"), | |
| 47 | 48 | route("actions/jobs/:job/log", "routes/repo/actions-log.ts"), | |
| 48 | 49 | route("automations", "routes/repo/automations.tsx"), | |
| 49 | 50 | route("plans", "routes/repo/plans.tsx"), |
| 1 | + | import type { Route } from "./+types/actions-artifact"; | |
| 2 | + | import { readArtifact } from "../../lib/artifacts.server"; | |
| 3 | + | import { actions } from "../../lib/services.server"; | |
| 4 | + | import { getViewer } from "../../lib/session.server"; | |
| 5 | + | ||
| 6 | + | /** One artifact of a run, for anyone who can see the run. */ | |
| 7 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 8 | + | const seen = await actions.run({ namespace: params.owner, name: params.repo }, getViewer(context), params.id); | |
| 9 | + | if (!seen.ok) throw new Response("Not found.", { status: 404 }); | |
| 10 | + | const bytes = await readArtifact(params.id, params.name); | |
| 11 | + | if (!bytes) throw new Response("That artifact is gone: they are kept for 14 days.", { status: 404 }); | |
| 12 | + | return new Response(bytes.buffer as ArrayBuffer, { | |
| 13 | + | headers: { | |
| 14 | + | "content-type": "application/gzip", | |
| 15 | + | "content-disposition": `attachment; filename="${params.name.replace(/"/g, "")}.tar.gz"`, | |
| 16 | + | }, | |
| 17 | + | }); | |
| 18 | + | } |
| 1 | − | import { AlertTriangle, ChevronRight, GitBranch, GitCommitHorizontal, Info, RotateCw, Square, XCircle } from "lucide-react"; | |
| 1 | + | import { AlertTriangle, ChevronRight, Download, GitBranch, GitCommitHorizontal, Info, Package, RotateCw, Square, XCircle } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect } from "react"; | |
| 3 | 3 | import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 7 | 7 | import type { Route } from "./+types/actions-run"; | |
| 8 | 8 | import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions"; | |
| 9 | 9 | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { listArtifacts } from "../../lib/artifacts.server"; | |
| 10 | 11 | import { actions } from "../../lib/services.server"; | |
| 11 | 12 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 12 | 13 | ||
| 18 | 19 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 19 | 20 | const viewer = getViewer(context); | |
| 20 | 21 | const detail = unwrap(await actions.run({ namespace: params.owner, name: params.repo }, viewer, params.id)); | |
| 21 | − | return { detail, member: roleIn(viewer, params.owner) != null }; | |
| 22 | + | const artifacts = await listArtifacts(params.id).catch(() => []); | |
| 23 | + | return { detail, artifacts, member: roleIn(viewer, params.owner) != null }; | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 24 | 26 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 114 | 116 | } | |
| 115 | 117 | ||
| 116 | 118 | export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 117 | − | const { detail, member } = loaderData; | |
| 119 | + | const { detail, artifacts, member } = loaderData; | |
| 118 | 120 | const { run, jobs, notes } = detail; | |
| 119 | 121 | const base = `/${params.owner}/${params.repo}`; | |
| 120 | 122 | const [search] = useSearchParams(); | |
| 205 | 207 | </div> | |
| 206 | 208 | )} | |
| 207 | 209 | <Notes notes={notes} /> | |
| 210 | + | {artifacts.length > 0 && ( | |
| 211 | + | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 212 | + | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 213 | + | <Package size={14} className="text-muted" /> | |
| 214 | + | Artifacts | |
| 215 | + | <span className="font-normal text-faint">· kept for 14 days</span> | |
| 216 | + | </h3> | |
| 217 | + | <ul className="mt-3 divide-y divide-line text-sm"> | |
| 218 | + | {artifacts.map((artifact) => ( | |
| 219 | + | <li key={artifact.name} className="flex items-center gap-3 py-2"> | |
| 220 | + | <span className="min-w-0 grow truncate font-mono text-[0.8125rem]">{artifact.name}</span> | |
| 221 | + | <span className="shrink-0 text-xs text-faint">{Math.max(1, Math.round(artifact.size / 1024))} KB</span> | |
| 222 | + | <a | |
| 223 | + | href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(artifact.name)}`} | |
| 224 | + | className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg" | |
| 225 | + | > | |
| 226 | + | <Download size={12} /> | |
| 227 | + | Download | |
| 228 | + | </a> | |
| 229 | + | </li> | |
| 230 | + | ))} | |
| 231 | + | </ul> | |
| 232 | + | </section> | |
| 233 | + | )} | |
| 208 | 234 | ||
| 209 | 235 | {jobs.length > 0 && ( | |
| 210 | 236 | <div className="grid gap-6 lg:grid-cols-[15rem_1fr]"> |
| 238 | 238 | test: | |
| 239 | 239 | runs-on: ubuntu-latest | |
| 240 | 240 | steps: | |
| 241 | − | - uses: actions/checkout@v4 | |
| 242 | − | - uses: actions/setup-node@v4 | |
| 241 | + | - uses: actions/checkout@v7 | |
| 242 | + | - uses: actions/setup-node@v7 | |
| 243 | 243 | with: | |
| 244 | − | node-version: 22 | |
| 244 | + | node-version: 24 | |
| 245 | 245 | - run: npm ci | |
| 246 | 246 | - run: npm test`; | |
| 247 | 247 |
| 14 | 14 | WEBHOOKS: ServiceBinding; | |
| 15 | 15 | AUTOMATIONS: ServiceBinding; | |
| 16 | 16 | ACTIONS: ServiceBinding; | |
| 17 | + | BLOBS: KVNamespace; | |
| 17 | 18 | } | |
| 18 | 19 | } | |
| 19 | 20 | interface Env extends Cloudflare.Env {} |
| 9 | 9 | "main": "./workers/app.ts", | |
| 10 | 10 | "routes": [{ "pattern": "g1t.sh", "custom_domain": true }], | |
| 11 | 11 | // The site holds no data of its own; everything goes through services. | |
| 12 | + | // GitHub Actions artifacts, as the API keeps them, for download from a run. | |
| 13 | + | "kv_namespaces": [{ "binding": "BLOBS", "id": "16a4232cb746418db53782aa068be693" }], | |
| 12 | 14 | "services": [ | |
| 13 | 15 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 14 | 16 | { "binding": "REPOS", "service": "g1t-repos" }, |
| 22 | 22 | "issue.assigned" => vec![("issues", Some("assigned"))], | |
| 23 | 23 | "comment.created" => vec![("issue_comment", Some("created"))], | |
| 24 | 24 | "review.completed" => vec![("pull_request_review", Some("submitted"))], | |
| 25 | + | "workflow.completed" => vec![("workflow_run", Some("completed"))], | |
| 25 | 26 | _ => Vec::new(), | |
| 26 | 27 | } | |
| 27 | 28 | } |
| 24 | 24 | "workflow_dispatch", | |
| 25 | 25 | "repository_dispatch", | |
| 26 | 26 | "workflow_call", | |
| 27 | + | "workflow_run", | |
| 27 | 28 | "merge_group", | |
| 28 | 29 | "create", | |
| 29 | 30 | "delete", | |
| 72 | 73 | pub crons: Vec<String>, | |
| 73 | 74 | /// For `workflow_dispatch` and `workflow_call`: the inputs, as written. | |
| 74 | 75 | pub inputs: Map<String, Value>, | |
| 76 | + | /// For `workflow_run`: the names of the workflows it follows. | |
| 77 | + | pub workflows: Vec<String>, | |
| 75 | 78 | } | |
| 76 | 79 | ||
| 77 | 80 | impl Trigger { | |
| 258 | 261 | if let Some(Value::Object(inputs)) = spec.get("inputs") { | |
| 259 | 262 | trigger.inputs = inputs.clone(); | |
| 260 | 263 | } | |
| 264 | + | trigger.workflows = texts(spec.get("workflows")); | |
| 261 | 265 | } | |
| 262 | 266 | Value::Array(entries) if event == "schedule" => { | |
| 263 | 267 | trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect(); | |
| 497 | 501 | strategy: | |
| 498 | 502 | matrix: | |
| 499 | 503 | os: [ubuntu-latest, windows-latest] | |
| 500 | − | node: [18, 20] | |
| 504 | + | node: [22, 24] | |
| 501 | 505 | steps: | |
| 502 | − | - uses: actions/checkout@v4 | |
| 503 | − | - uses: actions/setup-node@v4 | |
| 506 | + | - uses: actions/checkout@v7 | |
| 507 | + | - uses: actions/setup-node@v7 | |
| 504 | 508 | with: | |
| 505 | 509 | node-version: ${{ matrix.node }} | |
| 506 | 510 | - run: npm ci | |
| 528 | 532 | assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}"); | |
| 529 | 533 | assert_eq!(workflow.jobs.len(), 2); | |
| 530 | 534 | assert_eq!(workflow.jobs[1].needs, ["test"]); | |
| 531 | − | assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v4"); | |
| 535 | + | assert_eq!(workflow.jobs[0].steps[0].title(), "Run actions/checkout@v7"); | |
| 532 | 536 | assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci"); | |
| 533 | 537 | assert_eq!(workflow.jobs[0].steps[3].title(), "Test"); | |
| 534 | 538 | assert_eq!(workflow.job_order(), ["test", "deploy"]); | |
| 554 | 558 | #[test] | |
| 555 | 559 | fn notes_say_what_runs_differently() { | |
| 556 | 560 | let workflow = parse( | |
| 557 | − | "on: [push, release]\njobs:\n win:\n runs-on: windows-latest\n services:\n db: { image: postgres }\n steps:\n - uses: actions/cache@v4\n - uses: docker://alpine\n - run: dir\n shell: pwsh", | |
| 561 | + | "on: [push, release]\njobs:\n win:\n runs-on: windows-latest\n services:\n db: { image: postgres }\n steps:\n - uses: actions/cache@v6\n - uses: docker://alpine\n - run: dir\n shell: pwsh", | |
| 558 | 562 | ) | |
| 559 | 563 | .unwrap(); | |
| 560 | 564 | let unsupported: Vec<&str> = |
| 107 | 107 | pub commit: String, | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | + | /// `workflow.completed`: a GitHub Actions run finished. | |
| 111 | + | #[derive(Debug, Serialize)] | |
| 112 | + | #[serde(rename_all = "camelCase")] | |
| 113 | + | pub struct WorkflowEvent { | |
| 114 | + | pub run_id: String, | |
| 115 | + | pub repo_id: String, | |
| 116 | + | /// The workflow's name, and its file. | |
| 117 | + | pub workflow: String, | |
| 118 | + | pub path: String, | |
| 119 | + | /// The run's number among the workflow's runs. | |
| 120 | + | pub number: u64, | |
| 121 | + | /// The GitHub event that started it, such as `push`. | |
| 122 | + | pub event: String, | |
| 123 | + | /// `success`, `failure`, `cancelled` or `skipped`. | |
| 124 | + | pub conclusion: String, | |
| 125 | + | #[serde(rename = "ref")] | |
| 126 | + | pub git_ref: String, | |
| 127 | + | pub sha: String, | |
| 128 | + | /// The pull request it ran for, if any. | |
| 129 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 130 | + | pub pull: Option<u32>, | |
| 131 | + | } | |
| 132 | + | ||
| 110 | 133 | /// `review.completed`: a g1t agent finished reviewing a pull request, or | |
| 111 | 134 | /// could not. | |
| 112 | 135 | #[derive(Debug, Serialize)] |
| 15 | 15 | use crate::{User, Viewer}; | |
| 16 | 16 | ||
| 17 | 17 | /// Every event a webhook can be sent, in the order people are shown them. | |
| 18 | − | pub const EVENT_TYPES: [&str; 19] = [ | |
| 18 | + | pub const EVENT_TYPES: [&str; 20] = [ | |
| 19 | 19 | "git.push", | |
| 20 | 20 | "repo.created", | |
| 21 | 21 | "repo.forked", | |
| 33 | 33 | "pull.closed", | |
| 34 | 34 | "checks.completed", | |
| 35 | 35 | "review.completed", | |
| 36 | + | "workflow.completed", | |
| 36 | 37 | "queue.changed", | |
| 37 | 38 | "session.appended", | |
| 38 | 39 | ]; |
| 512 | 512 | ||
| 513 | 513 | job.log.step(0); | |
| 514 | 514 | job.log.line(&format!("Job: {}", job.spec["name"].as_str().unwrap_or_default())); | |
| 515 | − | job.log.line("Runner: g1t, Linux X64 (Debian bookworm, Node 22, Python 3, Go, Rust)"); | |
| 515 | + | job.log.line("Runner: g1t, Linux X64 (Debian bookworm, Node 24, Python 3, Go, Rust)"); | |
| 516 | 516 | if let Some(Value::Object(matrix)) = job.contexts.get("matrix") | |
| 517 | 517 | && !matrix.is_empty() | |
| 518 | 518 | { |
| 26 | 26 | "pull.closed", | |
| 27 | 27 | "checks.completed", | |
| 28 | 28 | "review.completed", | |
| 29 | + | "workflow.completed", | |
| 29 | 30 | "queue.changed", | |
| 30 | 31 | "session.appended", | |
| 31 | 32 | ] as const; |
| 72 | 72 | work: Fetcher, | |
| 73 | 73 | identity: Fetcher, | |
| 74 | 74 | runner: Fetcher, | |
| 75 | + | events: Fetcher, | |
| 75 | 76 | /// Seals secrets; absent until `ACTIONS_KEY` is set, when secrets | |
| 76 | 77 | /// cannot be saved. | |
| 77 | 78 | sealer: Option<Sealer>, | |
| 85 | 86 | work: env.service("WORK")?, | |
| 86 | 87 | identity: env.service("IDENTITY")?, | |
| 87 | 88 | runner: env.service("RUNNER")?, | |
| 89 | + | events: env.service("EVENTS")?, | |
| 88 | 90 | sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())), | |
| 89 | 91 | }) | |
| 90 | 92 | } |
| 779 | 779 | return Ok(()); | |
| 780 | 780 | } | |
| 781 | 781 | self.report_status(run, conclusion).await?; | |
| 782 | + | let published: Result<()> = g1t_kit::call( | |
| 783 | + | &self.events, | |
| 784 | + | "publish", | |
| 785 | + | &g1t_contracts::events::Publish { | |
| 786 | + | events: vec![g1t_contracts::events::NewEvent { | |
| 787 | + | kind: "workflow.completed", | |
| 788 | + | source: "actions", | |
| 789 | + | repo_id: Some(run.repo_id.clone()), | |
| 790 | + | actor: run.actor_id.clone(), | |
| 791 | + | data: g1t_contracts::events::WorkflowEvent { | |
| 792 | + | run_id: run.id.clone(), | |
| 793 | + | repo_id: run.repo_id.clone(), | |
| 794 | + | workflow: run.name.clone(), | |
| 795 | + | path: run.path.clone(), | |
| 796 | + | number: run.number, | |
| 797 | + | event: run.event.clone(), | |
| 798 | + | conclusion: conclusion.to_owned(), | |
| 799 | + | git_ref: run.git_ref.clone(), | |
| 800 | + | sha: run.sha.clone(), | |
| 801 | + | pull: run.pull, | |
| 802 | + | }, | |
| 803 | + | }], | |
| 804 | + | }, | |
| 805 | + | ) | |
| 806 | + | .await; | |
| 807 | + | if let Err(error) = published { | |
| 808 | + | worker::console_error!("actions: could not publish workflow.completed: {error}"); | |
| 809 | + | } | |
| 782 | 810 | // The next run waiting in its concurrency group. | |
| 783 | 811 | if let Some(group) = &run.concurrency_group { | |
| 784 | 812 | let next = self |
| 238 | 238 | trusted, | |
| 239 | 239 | }) | |
| 240 | 240 | } | |
| 241 | + | "workflow_run" => { | |
| 242 | + | // A run of a workflow_run workflow does not start another, | |
| 243 | + | // so two such workflows cannot set each other off. | |
| 244 | + | if data["event"].as_str() == Some("workflow_run") { | |
| 245 | + | return Ok(None); | |
| 246 | + | } | |
| 247 | + | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; | |
| 248 | + | let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned(); | |
| 249 | + | let name = data["workflow"].as_str().unwrap_or_default(); | |
| 250 | + | let payload = json!({ | |
| 251 | + | "action": "completed", | |
| 252 | + | "workflow_run": { | |
| 253 | + | "id": data["runId"], | |
| 254 | + | "name": name, | |
| 255 | + | "path": data["path"], | |
| 256 | + | "event": data["event"], | |
| 257 | + | "status": "completed", | |
| 258 | + | "conclusion": data["conclusion"], | |
| 259 | + | "head_sha": data["sha"], | |
| 260 | + | "head_branch": head_branch, | |
| 261 | + | "run_number": data["number"], | |
| 262 | + | "html_url": format!("{SITE}/{}/{}/actions/runs/{}", repo.namespace, repo.name, data["runId"].as_str().unwrap_or_default()), | |
| 263 | + | "pull_requests": data["pull"].as_u64().map(|n| vec![json!({ "number": n })]).unwrap_or_default(), | |
| 264 | + | }, | |
| 265 | + | "workflow": { "name": name, "path": data["path"] }, | |
| 266 | + | "repository": payload::repository(repo), | |
| 267 | + | "sender": payload::user(sender), | |
| 268 | + | }); | |
| 269 | + | let mut subject = on_default(sha, payload, format!("After {name}"), None); | |
| 270 | + | // Branch filters apply to the branch the followed run was on. | |
| 271 | + | subject.filter_ref = format!("refs/heads/{head_branch}"); | |
| 272 | + | Some(subject) | |
| 273 | + | } | |
| 241 | 274 | "issues" | "issue_comment" => { | |
| 242 | 275 | let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) }; | |
| 243 | 276 | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; | |
| 330 | 363 | } | |
| 331 | 364 | }; | |
| 332 | 365 | let Some(trigger) = workflow.trigger(event_name) else { continue }; | |
| 366 | + | // workflow_run follows the workflows it names. | |
| 367 | + | if event_name == "workflow_run" { | |
| 368 | + | let followed = subject.payload["workflow_run"]["name"].as_str().unwrap_or_default(); | |
| 369 | + | if !trigger.workflows.iter().any(|name| name == followed) { | |
| 370 | + | continue; | |
| 371 | + | } | |
| 372 | + | } | |
| 333 | 373 | if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? { | |
| 334 | 374 | continue; | |
| 335 | 375 | } |
| 23 | 23 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 24 | 24 | { "binding": "WORK", "service": "g1t-work" }, | |
| 25 | 25 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 26 | − | { "binding": "RUNNER", "service": "g1t-runner" } | |
| 26 | + | { "binding": "RUNNER", "service": "g1t-runner" }, | |
| 27 | + | { "binding": "EVENTS", "service": "g1t-events" } | |
| 27 | 28 | ], | |
| 28 | 29 | // Every event on the bus: what starts workflows, and pushes that change them. | |
| 29 | 30 | "queues": { |
| 13 | 13 | COPY services services | |
| 14 | 14 | RUN cargo build --release --package g1t-runner | |
| 15 | 15 | ||
| 16 | − | FROM node:22-bookworm-slim | |
| 16 | + | FROM node:24-bookworm-slim | |
| 17 | 17 | # Workflows expect GitHub's runner layout under /home/runner, and sudo | |
| 18 | 18 | # without a password. | |
| 19 | 19 | RUN apt-get update \ |