Commit

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.

syntaqxcommitted Parent426d042Browse files
22 files+214−210/22 viewed
+13−5
2929
3030 | On GitHub | On g1t |
3131 | --- | --- |
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. |
3333 | `jobs`, `needs`, `if`, `outputs`, `env`, `defaults`, `timeout-minutes`, `continue-on-error` | The same. |
3434 | `strategy.matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`, a matrix from `fromJSON(needs.…)` | The same. |
3535 | `concurrency` with `cancel-in-progress` | The same. |
3636 | `${{ }}` expressions: every operator, function and context | The same, including `hashFiles`, `success()`, `failure()`, `always()` and `cancelled()`. |
3737 | `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. |
3939 | Composite actions | The same. |
4040 | `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. |
4141 | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. |
4242 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. |
4343 | `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. |
4446
4547 The **Actions** page of a workflow says, under *How this runs on g1t*,
4648 anything in it that runs differently.
5153 `runs-on: windows-latest` or `macos-latest` fails, and says so.
5254 - **Docker** container actions, `services:` containers and `container:`.
5355 - **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.
5659 - **Environments' protection rules**. A job with `environment:` runs with
5760 the repository's secrets.
5861
5962 ## The runner
6063
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,
6265 `build-essential`, `git`, `curl`, `jq` and passwordless `sudo`, in GitHub's
6366 layout (`/home/runner/work`, `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`).
6467 `runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04`, `self-hosted` and
8184
8285 ## Pull requests
8386
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+
8492 A run on a pull request's latest commit is a check on it:
8593
8694 - While a workflow runs, the pull request waits for it before merging.
+1−0
6161 | `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. |
6262 | `checks.completed` | An issue's acceptance checks finished on a pull request. |
6363 | `review.completed` | A g1t agent reviewed a pull request. |
64+| `workflow.completed` | A GitHub Actions run finished: `if: { conclusion: failure }` to act on failures. |
6465 | `queue.changed` | The merge queue changed. |
6566
6667 A schedule has five fields: minute, hour, day of the month, month and day
+1−0
6767 | `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`. |
6868 | `checks.completed` | An issue's acceptance checks finished on a pull request. `data.status` is `passed`, `failed` or `errored`. |
6969 | `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`. |
7071 | `queue.changed` | The merge queue gained, lost or settled an entry. |
7172 | `session.appended` | An agent's session grew. Busy: choose it only if you need it. |
7273
+1−1
2222 title: "Pull requests",
2323 events: ["pull.opened", "pull.ready", "pull.updated", "pull.merge_requested", "pull.merged", "pull.closed"],
2424 },
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"] },
2626 { title: "Agents", events: ["session.appended"] },
2727 ];
2828
+34−0
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+}
+1−0
4444 route("queue", "routes/repo/queue.tsx"),
4545 route("actions", "routes/repo/actions.tsx"),
4646 route("actions/runs/:id", "routes/repo/actions-run.tsx"),
47+ route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"),
4748 route("actions/jobs/:job/log", "routes/repo/actions-log.ts"),
4849 route("automations", "routes/repo/automations.tsx"),
4950 route("plans", "routes/repo/plans.tsx"),
+18−0
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+}
+29−3
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";
22 import { type ReactNode, useEffect } from "react";
33 import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
44
77 import type { Route } from "./+types/actions-run";
88 import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
99 import { Button, ErrorText, TimeAgo } from "../../components/ui";
10+import { listArtifacts } from "../../lib/artifacts.server";
1011 import { actions } from "../../lib/services.server";
1112 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1213
1819 export async function loader({ params, context }: Route.LoaderArgs) {
1920 const viewer = getViewer(context);
2021 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 };
2224 }
2325
2426 export async function action({ request, params, context }: Route.ActionArgs) {
114116 }
115117
116118 export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
117− const { detail, member } = loaderData;
119+ const { detail, artifacts, member } = loaderData;
118120 const { run, jobs, notes } = detail;
119121 const base = `/${params.owner}/${params.repo}`;
120122 const [search] = useSearchParams();
205207 </div>
206208 )}
207209 <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+ )}
208234
209235 {jobs.length > 0 && (
210236 <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
+3−3
238238 test:
239239 runs-on: ubuntu-latest
240240 steps:
241− - uses: actions/checkout@v4
242− - uses: actions/setup-node@v4
241+ - uses: actions/checkout@v7
242+ - uses: actions/setup-node@v7
243243 with:
244− node-version: 22
244+ node-version: 24
245245 - run: npm ci
246246 - run: npm test`;
247247
+1−0
1414 WEBHOOKS: ServiceBinding;
1515 AUTOMATIONS: ServiceBinding;
1616 ACTIONS: ServiceBinding;
17+ BLOBS: KVNamespace;
1718 }
1819 }
1920 interface Env extends Cloudflare.Env {}
+2−0
99 "main": "./workers/app.ts",
1010 "routes": [{ "pattern": "g1t.sh", "custom_domain": true }],
1111 // 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" }],
1214 "services": [
1315 { "binding": "IDENTITY", "service": "g1t-identity" },
1416 { "binding": "REPOS", "service": "g1t-repos" },
+1−0
2222 "issue.assigned" => vec![("issues", Some("assigned"))],
2323 "comment.created" => vec![("issue_comment", Some("created"))],
2424 "review.completed" => vec![("pull_request_review", Some("submitted"))],
25+ "workflow.completed" => vec![("workflow_run", Some("completed"))],
2526 _ => Vec::new(),
2627 }
2728 }
+9−5
2424 "workflow_dispatch",
2525 "repository_dispatch",
2626 "workflow_call",
27+ "workflow_run",
2728 "merge_group",
2829 "create",
2930 "delete",
7273 pub crons: Vec<String>,
7374 /// For `workflow_dispatch` and `workflow_call`: the inputs, as written.
7475 pub inputs: Map<String, Value>,
76+ /// For `workflow_run`: the names of the workflows it follows.
77+ pub workflows: Vec<String>,
7578 }
7679
7780 impl Trigger {
258261 if let Some(Value::Object(inputs)) = spec.get("inputs") {
259262 trigger.inputs = inputs.clone();
260263 }
264+ trigger.workflows = texts(spec.get("workflows"));
261265 }
262266 Value::Array(entries) if event == "schedule" => {
263267 trigger.crons = entries.iter().filter_map(|entry| text(entry.get("cron"))).collect();
497501 strategy:
498502 matrix:
499503 os: [ubuntu-latest, windows-latest]
500− node: [18, 20]
504+ node: [22, 24]
501505 steps:
502− - uses: actions/checkout@v4
503− - uses: actions/setup-node@v4
506+ - uses: actions/checkout@v7
507+ - uses: actions/setup-node@v7
504508 with:
505509 node-version: ${{ matrix.node }}
506510 - run: npm ci
528532 assert_eq!(workflow.concurrency.as_ref().unwrap().group, "ci-${{ github.ref }}");
529533 assert_eq!(workflow.jobs.len(), 2);
530534 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");
532536 assert_eq!(workflow.jobs[0].steps[2].title(), "Run npm ci");
533537 assert_eq!(workflow.jobs[0].steps[3].title(), "Test");
534538 assert_eq!(workflow.job_order(), ["test", "deploy"]);
554558 #[test]
555559 fn notes_say_what_runs_differently() {
556560 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",
558562 )
559563 .unwrap();
560564 let unsupported: Vec<&str> =
+23−0
107107 pub commit: String,
108108 }
109109
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+
110133 /// `review.completed`: a g1t agent finished reviewing a pull request, or
111134 /// could not.
112135 #[derive(Debug, Serialize)]
+2−1
1515 use crate::{User, Viewer};
1616
1717 /// 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] = [
1919 "git.push",
2020 "repo.created",
2121 "repo.forked",
3333 "pull.closed",
3434 "checks.completed",
3535 "review.completed",
36+ "workflow.completed",
3637 "queue.changed",
3738 "session.appended",
3839 ];
+1−1
512512
513513 job.log.step(0);
514514 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)");
516516 if let Some(Value::Object(matrix)) = job.contexts.get("matrix")
517517 && !matrix.is_empty()
518518 {
+1−0
2626 "pull.closed",
2727 "checks.completed",
2828 "review.completed",
29+ "workflow.completed",
2930 "queue.changed",
3031 "session.appended",
3132 ] as const;
+2−0
7272 work: Fetcher,
7373 identity: Fetcher,
7474 runner: Fetcher,
75+ events: Fetcher,
7576 /// Seals secrets; absent until `ACTIONS_KEY` is set, when secrets
7677 /// cannot be saved.
7778 sealer: Option<Sealer>,
8586 work: env.service("WORK")?,
8687 identity: env.service("IDENTITY")?,
8788 runner: env.service("RUNNER")?,
89+ events: env.service("EVENTS")?,
8890 sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())),
8991 })
9092 }
+28−0
779779 return Ok(());
780780 }
781781 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+ }
782810 // The next run waiting in its concurrency group.
783811 if let Some(group) = &run.concurrency_group {
784812 let next = self
+40−0
238238 trusted,
239239 })
240240 }
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+ }
241274 "issues" | "issue_comment" => {
242275 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
243276 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
330363 }
331364 };
332365 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+ }
333373 if !trigger.wants_type(action) || !self.passes(repo, ws, trigger, subject, event_name).await? {
334374 continue;
335375 }
+2−1
2323 { "binding": "REPOS", "service": "g1t-repos" },
2424 { "binding": "WORK", "service": "g1t-work" },
2525 { "binding": "IDENTITY", "service": "g1t-identity" },
26− { "binding": "RUNNER", "service": "g1t-runner" }
26+ { "binding": "RUNNER", "service": "g1t-runner" },
27+ { "binding": "EVENTS", "service": "g1t-events" }
2728 ],
2829 // Every event on the bus: what starts workflows, and pushes that change them.
2930 "queues": {
+1−1
1313 COPY services services
1414 RUN cargo build --release --package g1t-runner
1515
16−FROM node:22-bookworm-slim
16+FROM node:24-bookworm-slim
1717 # Workflows expect GitHub's runner layout under /home/runner, and sudo
1818 # without a password.
1919 RUN apt-get update \