Skip to content

Commit

Workflow status badges

/{workspace}/{repo}/actions/workflows/{file}/badge.svg says how the workflow's newest finished run went (passing, failing, cancelled or no status), on ?branch= (the default branch, else any) and ?event=. Public repositories' load for anyone and are cached a minute; private ones' only for viewers who can see the repository, never cached publicly. The workflow's page has Create status badge, with a preview and the Markdown to copy.

syntaqxcommitted Parentfd9045aBrowse files
5 files+248−60/5 viewed
+39−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { WorkflowRun } from "@g1t/contracts";
5+
6+import { badgeMarkdown, badgeState, badgeSvg, badgeUrl, textWidth } from "./badge.ts";
7+
8+const run = (status: WorkflowRun["status"], conclusion: WorkflowRun["conclusion"]) => ({ status, conclusion }) as WorkflowRun;
9+
10+test("a badge says how the newest finished run went", () => {
11+ assert.equal(badgeState([]), "no status");
12+ assert.equal(badgeState([run("in_progress", null), run("completed", "success")]), "passing");
13+ assert.equal(badgeState([run("completed", "failure"), run("completed", "success")]), "failing");
14+ assert.equal(badgeState([run("completed", "cancelled")]), "cancelled");
15+ assert.equal(badgeState([run("completed", "skipped")]), "passing");
16+ assert.equal(badgeState([run("queued", null)]), "no status");
17+});
18+
19+test("the badge is an SVG with its label and state, escaped", () => {
20+ const svg = badgeSvg("CI <main>", "passing");
21+ assert.match(svg, /^<svg xmlns="http:\/\/www.w3.org\/2000\/svg"/);
22+ assert.match(svg, /<title>CI &lt;main&gt;: passing<\/title>/);
23+ assert.match(svg, /fill="#2ea043"/);
24+ assert.doesNotMatch(svg, /<main>/);
25+ assert.ok(textWidth("failing") > textWidth("ill"));
26+ assert.match(badgeSvg("x".repeat(80), "failing"), /x{59}…/);
27+});
28+
29+test("its address and Markdown name the workflow file, and a branch or event when chosen", () => {
30+ assert.equal(badgeUrl("https://g1t.sh", "acme/web", "ci.yml"), "https://g1t.sh/acme/web/actions/workflows/ci.yml/badge.svg");
31+ assert.equal(
32+ badgeUrl("https://g1t.sh", "acme/web", "ci.yml", { branch: "release/1.x", event: "push" }),
33+ "https://g1t.sh/acme/web/actions/workflows/ci.yml/badge.svg?branch=release%2F1.x&event=push",
34+ );
35+ assert.equal(
36+ badgeMarkdown("https://g1t.sh", "acme/web", { name: "CI [main]", file: "ci.yml" }),
37+ "[![CI main](https://g1t.sh/acme/web/actions/workflows/ci.yml/badge.svg)](https://g1t.sh/acme/web/actions?workflow=ci.yml)",
38+ );
39+});
+88−0
1+/**
2+ * Workflow status badges: an SVG saying how a workflow's latest finished
3+ * run went, at `/{workspace}/{repo}/actions/workflows/{file}/badge.svg`,
4+ * and the Markdown that shows one.
5+ */
6+
7+import type { WorkflowRun } from "@g1t/contracts";
8+
9+export type BadgeState = "passing" | "failing" | "cancelled" | "no status";
10+
11+const COLORS: Record<BadgeState, string> = {
12+ passing: "#2ea043",
13+ failing: "#cf222e",
14+ cancelled: "#6e7681",
15+ "no status": "#6e7681",
16+};
17+const LABEL_COLOR = "#2b2a33";
18+
19+/** What the newest finished run among `runs` (newest first) came to. */
20+export function badgeState(runs: WorkflowRun[]): BadgeState {
21+ const run = runs.find((candidate) => candidate.status === "completed");
22+ switch (run?.conclusion) {
23+ case "success":
24+ case "skipped":
25+ return "passing";
26+ case "failure":
27+ return "failing";
28+ case "cancelled":
29+ return "cancelled";
30+ default:
31+ return "no status";
32+ }
33+}
34+
35+/** Roughly how wide `text` is in 11px Verdana, as badges are drawn. */
36+export function textWidth(text: string): number {
37+ let width = 0;
38+ for (const c of text) {
39+ if ("iljtfI.,:;'|!".includes(c)) width += 3.5;
40+ else if ("mwMW@%".includes(c)) width += 10.5;
41+ else if (c === " ") width += 3.8;
42+ else if (/[A-Z0-9]/.test(c)) width += 7.6;
43+ else width += 6.6;
44+ }
45+ return Math.ceil(width);
46+}
47+
48+function escape(text: string): string {
49+ return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
50+}
51+
52+/** A flat badge: `label` on dark gray, `state` in its color. */
53+export function badgeSvg(label: string, state: BadgeState): string {
54+ const shown = label.length > 60 ? `${label.slice(0, 59)}…` : label;
55+ const left = textWidth(shown) + 12;
56+ const right = textWidth(state) + 12;
57+ const width = left + right;
58+ const title = escape(`${shown}: ${state}`);
59+ const text = (x: number, value: string) =>
60+ `<text x="${x}" y="15" fill="#010101" fill-opacity=".3">${escape(value)}</text><text x="${x}" y="14">${escape(value)}</text>`;
61+ return [
62+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${title}">`,
63+ `<title>${title}</title>`,
64+ `<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>`,
65+ `<clipPath id="r"><rect width="${width}" height="20" rx="3" fill="#fff"/></clipPath>`,
66+ `<g clip-path="url(#r)"><rect width="${left}" height="20" fill="${LABEL_COLOR}"/><rect x="${left}" width="${right}" height="20" fill="${COLORS[state]}"/><rect width="${width}" height="20" fill="url(#s)"/></g>`,
67+ `<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">`,
68+ text(left / 2, shown),
69+ text(left + right / 2, state),
70+ `</g></svg>`,
71+ ].join("");
72+}
73+
74+/** The badge's address, with its branch and event when chosen. */
75+export function badgeUrl(site: string, repo: string, file: string, options: { branch?: string; event?: string } = {}): string {
76+ const query = new URLSearchParams();
77+ if (options.branch) query.set("branch", options.branch);
78+ if (options.event) query.set("event", options.event);
79+ const tail = query.size > 0 ? `?${query}` : "";
80+ return `${site}/${repo}/actions/workflows/${encodeURIComponent(file)}/badge.svg${tail}`;
81+}
82+
83+/** Markdown for a badge that links to the workflow's runs. */
84+export function badgeMarkdown(site: string, repo: string, workflow: { name: string; file: string }, options: { branch?: string; event?: string } = {}): string {
85+ const alt = workflow.name.replace(/[[\]]/g, "");
86+ const runs = `${site}/${repo}/actions?workflow=${encodeURIComponent(workflow.file)}`;
87+ return `[![${alt}](${badgeUrl(site, repo, workflow.file, options)})](${runs})`;
88+}
+1−0
180180 route("actions/jobs/:job/log", "routes/repo/actions-log.ts"),
181181 route("actions/jobs/:job/log.txt", "routes/repo/actions-job-log-download.ts"),
182182 route("actions/runs/:id/logs.zip", "routes/repo/actions-logs-download.ts"),
183+ route("actions/workflows/:file/badge.svg", "routes/repo/actions-badge.ts"),
183184 route("deployments", "routes/repo/deployments.tsx"),
184185 route("deployments/:id", "routes/repo/deployment.tsx"),
185186 // Security: an overview, then a page for each part. Old links to
+47−0
1+/**
2+ * A workflow's status badge: an SVG saying how its newest finished run went,
3+ * on `?branch=` (the default branch, else any) and `?event=` when given.
4+ * Anyone may load a public repository's; a private repository's needs
5+ * someone who can see it, and is never cached where others could read it.
6+ */
7+import type { Route } from "./+types/actions-badge";
8+import { badgeState, badgeSvg } from "../../lib/badge";
9+import { actions, repos } from "../../lib/services.server";
10+import { getViewer } from "../../lib/session.server";
11+
12+function svg(body: string, isPrivate: boolean, status = 200): Response {
13+ return new Response(body, {
14+ status,
15+ headers: {
16+ "content-type": "image/svg+xml; charset=utf-8",
17+ // Shown in READMEs elsewhere, which fetch it through their own caches.
18+ "cache-control": isPrivate ? "private, no-store" : "public, max-age=60, s-maxage=60",
19+ "x-content-type-options": "nosniff",
20+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
21+ },
22+ });
23+}
24+
25+export async function loader({ params, context, request }: Route.LoaderArgs) {
26+ const viewer = getViewer(context);
27+ const path = { namespace: params.owner, name: params.repo };
28+ const query = new URL(request.url).searchParams;
29+ const branch = query.get("branch")?.trim() || undefined;
30+ const event = query.get("event")?.trim() || undefined;
31+ const repo = await repos.get(path, viewer).catch(() => null);
32+ // Not there, or not this viewer's to see: the same answer either way.
33+ if (!repo?.ok) return new Response("Not found.\n", { status: 404, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
34+ const isPrivate = repo.value.isPrivate;
35+ const workflows = await actions.workflows(path, viewer);
36+ const file = params.file;
37+ const workflow = workflows.ok ? workflows.value.find((w) => w.path.endsWith(`/${file}`) || w.id === file) : undefined;
38+ if (!workflow) return svg(badgeSvg(file, "no status"), isPrivate, 404);
39+ const runsOn = async (onBranch: string | undefined) => {
40+ const runs = await actions.runs(path, viewer, { workflow: workflow.id, branch: onBranch, event, limit: 20 });
41+ return runs.ok ? runs.value : [];
42+ };
43+ // The default branch unless one is asked for; any branch when it has none.
44+ let runs = await runsOn(branch ?? repo.value.defaultBranch);
45+ if (!branch && !runs.some((run) => run.status === "completed")) runs = await runsOn(undefined);
46+ return svg(badgeSvg(workflow.name, badgeState(runs)), isPrivate);
47+}
+73−6
1−import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
1+import { AlertTriangle, BadgeCheck, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
22 import { useEffect, useRef, useState } from "react";
33 import { Form, Link, useSearchParams } from "react-router";
44
88 import { page } from "../../lib/meta";
99 import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
1010 import { AddCiPrompt } from "../../components/add-ci";
11−import { Button, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
11+import { Button, ComputeNote, CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
1212 import { CheckboxOption } from "../../components/ui/checkbox";
13+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog";
14+import { useAddresses } from "../../lib/addresses";
15+import { badgeMarkdown, badgeUrl } from "../../lib/badge";
1316 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1417 import { computeNoteFor } from "../../lib/compute.server";
1518 import { actions } from "../../lib/services.server";
223226 );
224227 }
225228
229+/** The address of a status badge for the workflow, and Markdown to show it, for a branch and event if chosen. */
230+function StatusBadge({ workflow, repo }: { workflow: Workflow; repo: string }) {
231+ const { site } = useAddresses();
232+ const [branch, setBranch] = useState("");
233+ const [event, setEvent] = useState("");
234+ const file = workflow.path.split("/").pop() ?? workflow.path;
235+ const options = { branch: branch.trim() || undefined, event: event || undefined };
236+ const control = "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent-dim";
237+ return (
238+ <Dialog>
239+ <DialogTrigger asChild>
240+ <Button type="button" variant="quiet">
241+ <BadgeCheck size={14} />
242+ Create status badge
243+ </Button>
244+ </DialogTrigger>
245+ <DialogContent>
246+ <DialogHeader>
247+ <DialogTitle>Create status badge</DialogTitle>
248+ <DialogDescription>
249+ A badge that shows how {workflow.name}'s latest finished run went. Paste it into a README.
250+ </DialogDescription>
251+ </DialogHeader>
252+ <div className="grid gap-3 sm:grid-cols-2">
253+ <label className="block">
254+ <span className="mb-1 block text-xs font-medium text-muted">Branch</span>
255+ <input
256+ value={branch}
257+ onChange={(e) => setBranch(e.target.value)}
258+ placeholder="The default branch"
259+ className={`${control} font-mono`}
260+ autoComplete="off"
261+ />
262+ </label>
263+ <div>
264+ <span className="mb-1 block text-xs font-medium text-muted">Event</span>
265+ <Select value={event || "any"} onValueChange={(value) => setEvent(value === "any" ? "" : value)}>
266+ <SelectTrigger size="sm" aria-label="Event">
267+ <SelectValue />
268+ </SelectTrigger>
269+ <SelectContent>
270+ <SelectItem value="any">Any event</SelectItem>
271+ {workflow.events.map((name) => (
272+ <SelectItem key={name} value={name}>
273+ {name}
274+ </SelectItem>
275+ ))}
276+ </SelectContent>
277+ </Select>
278+ </div>
279+ </div>
280+ <div className="flex min-h-10 items-center rounded-lg border border-line bg-bg px-3 py-2">
281+ <img src={badgeUrl(site, repo, file, options)} alt={`${workflow.name} status`} height={20} />
282+ </div>
283+ <CopyLine text={badgeMarkdown(site, repo, { name: workflow.name, file }, options)} />
284+ <p className="text-xs text-muted">
285+ Anyone can see a public repository's badge. A private repository's shows only to people who can see the repository.
286+ </p>
287+ </DialogContent>
288+ </Dialog>
289+ );
290+}
291+
226292 function WorkflowHeader({ workflow, base, member, manage }: { workflow: Workflow; base: string; member: boolean; manage: boolean }) {
227293 return (
228294 <div className="space-y-4">
237303 <p className="mt-1.5 text-xs text-muted">On {workflow.events.map((e) => EVENT_WORDS[e] ?? e).join(", ")}</p>
238304 )}
239305 </div>
240− {member && !workflow.error && (
241− <div className="flex items-center gap-2">
242− {workflow.dispatch && workflow.state === "active" && <RunWorkflow workflow={workflow} />}
243− {manage && <Form method="post">
306+ {!workflow.error && (
307+ <div className="flex flex-wrap items-center gap-2">
308+ <StatusBadge workflow={workflow} repo={base.slice(1)} />
309+ {member && workflow.dispatch && workflow.state === "active" && <RunWorkflow workflow={workflow} />}
310+ {member && manage && <Form method="post">
244311 <input type="hidden" name="intent" value="toggle" />
245312 <input type="hidden" name="workflow" value={workflow.id} />
246313 <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} />