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.
5 files+248−60/5 viewed
| 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 <main>: 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 | + | "[](https://g1t.sh/acme/web/actions?workflow=ci.yml)", | |
| 38 | + | ); | |
| 39 | + | }); |
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 `[})](${runs})`; | |
| 88 | + | } |
| 180 | 180 | route("actions/jobs/:job/log", "routes/repo/actions-log.ts"), | |
| 181 | 181 | route("actions/jobs/:job/log.txt", "routes/repo/actions-job-log-download.ts"), | |
| 182 | 182 | route("actions/runs/:id/logs.zip", "routes/repo/actions-logs-download.ts"), | |
| 183 | + | route("actions/workflows/:file/badge.svg", "routes/repo/actions-badge.ts"), | |
| 183 | 184 | route("deployments", "routes/repo/deployments.tsx"), | |
| 184 | 185 | route("deployments/:id", "routes/repo/deployment.tsx"), | |
| 185 | 186 | // Security: an overview, then a page for each part. Old links to |
| 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 | + | } |
| 1 | − | import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react"; | |
| 1 | + | import { AlertTriangle, BadgeCheck, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useRef, useState } from "react"; | |
| 3 | 3 | import { Form, Link, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { Notes, StatusIcon, duration, shortRef } from "../../components/actions"; | |
| 10 | 10 | 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"; | |
| 12 | 12 | 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"; | |
| 13 | 16 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 14 | 17 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 15 | 18 | import { actions } from "../../lib/services.server"; | |
| ⋯ | |||
| 223 | 226 | ); | |
| 224 | 227 | } | |
| 225 | 228 | ||
| 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 | + | ||
| 226 | 292 | function WorkflowHeader({ workflow, base, member, manage }: { workflow: Workflow; base: string; member: boolean; manage: boolean }) { | |
| 227 | 293 | return ( | |
| 228 | 294 | <div className="space-y-4"> | |
| ⋯ | |||
| 237 | 303 | <p className="mt-1.5 text-xs text-muted">On {workflow.events.map((e) => EVENT_WORDS[e] ?? e).join(", ")}</p> | |
| 238 | 304 | )} | |
| 239 | 305 | </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"> | |
| 244 | 311 | <input type="hidden" name="intent" value="toggle" /> | |
| 245 | 312 | <input type="hidden" name="workflow" value={workflow.id} /> | |
| 246 | 313 | <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} /> | |