What's live
craftz status reads what a repository has out in the world, where check reads its files and
settings its setup. Each line is one thing, marked ✓ or ✗, and it exits 1 when any is
✗, so it can be waited on after a merge or a release.
- CI: the latest run on
mainof each workflow, like CI and Release. - Pull requests: how many are open.
- npm, for a package: the version npm serves as
latest, next to the one inpackage.json. A version npm doesn't have yet is waiting to be released or approved, which is said, not marked. - The build, for a site: the Worker's latest build, its outcome, commit and deploy command. One still running is said, not marked.
- The addresses, for a site: the domain answers, and
workers.devdoesn't when the config turns it off. A site behind Cloudflare Access answers with a redirect to its login, which counts as answering.
--log prints the latest build's log after, which is where a failed deploy says why.
It needs gh logged in, and cf for a site. What can't be read is said, and marked ✗,
rather than taken as fine.
import { execFileSync } from "node:child_process";
import { cf, cloudflareLogin, workerTag } from "./settings.ts";
import { OWNER, type Pkg } from "./reference.ts";
import type { Worker } from "./worker.ts";
/** One line of the status: what it is, what was found, and whether it's fine. */
export type Line = { what: string; found: string; ok: boolean };
type Run = { workflowName: string; conclusion: string; status: string; headSha: string };
type Build = {
build_uuid: string;
status: string;
build_outcome: string | null;
created_on: string;
build_trigger_metadata?: { commit_hash?: string; deploy_command?: string };
};
const gh = (...args: string[]): string =>
execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
const short = (sha = ""): string => sha.slice(0, 7);
// A time to the minute, in UTC, as Cloudflare gives it.
const when = (iso: string): string => `${iso.slice(0, 16).replace("T", " ")} UTC`;
// The latest run of each workflow on main, newest first as gh lists them.
export function runsIn(runs: Run[]): Line[] {
const latest = new Map<string, Run>();
for (const run of runs) if (!latest.has(run.workflowName)) latest.set(run.workflowName, run);
return [...latest.values()].map((run) => {
const done = run.status === "completed";
const found = done
? `${run.conclusion} on ${short(run.headSha)}`
: `${run.status} on ${short(run.headSha)}`;
// A run that's going hasn't failed, and one that was skipped has nothing to do.
const ok = !done || ["success", "skipped", "neutral"].includes(run.conclusion);
return { what: run.workflowName, found, ok };
});
}
/** The latest build, as a line. */
export function buildIn(build: Build | undefined): Line {
if (!build) return { what: "build", found: "none yet", ok: false };
const meta = build.build_trigger_metadata ?? {};
const commit = meta.commit_hash ? ` on ${short(meta.commit_hash)}` : "";
const with_ = meta.deploy_command ? `, deployed with ${meta.deploy_command}` : "";
if (build.status !== "stopped") {
return {
what: "build",
found: `${build.status}${commit}, started ${when(build.created_on)}`,
ok: true,
};
}
const outcome = build.build_outcome ?? "unknown";
return {
what: "build",
found: `${outcome}${commit}, ${when(build.created_on)}${outcome === "success" ? with_ : ""}`,
ok: outcome === "success",
};
}
/** What npm serves as latest, next to the version in package.json. */
export function npmIn(latest: string | undefined, version: string | undefined): Line {
if (!latest) return { what: "npm", found: "not published", ok: true };
if (latest === version) return { what: "npm", found: `${latest}, as package.json`, ok: true };
return {
what: "npm",
found: `${latest}, and package.json is at ${version}, not released yet`,
ok: true,
};
}
// Whether an address answers, without following a redirect: a site behind Access redirects to
// its login.
async function answers(url: string): Promise<number | string> {
try {
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(10_000) });
return response.status;
} catch (error) {
return (error as Error).message;
}
}
async function addresses(site: Worker, dev?: string): Promise<Line[]> {
const lines: Line[] = [];
if (site.domain) {
const code = await answers(`https://${site.domain}/`);
const ok = typeof code === "number" && code < 400;
lines.push({ what: site.domain, found: String(code), ok });
}
if (dev) {
const code = await answers(dev);
// Off means no answer: Cloudflare's 404 for a Worker with workers.dev turned off.
const ok = site.workersDev ? typeof code === "number" && code < 400 : code === 404;
lines.push({
what: "workers.dev",
found: `${code}${site.workersDev ? "" : ", turned off"}`,
ok,
});
}
return lines;
}
/** Everything live about one repository, and the latest build's id for `--log`. */
export async function status(
repo: string,
pkg: Pkg & { version?: string },
site?: Worker,
): Promise<{ lines: Line[]; build?: string }> {
const lines: Line[] = [];
try {
const runs = JSON.parse(
gh(
"run",
"list",
"-R",
`${OWNER}/${repo}`,
"--branch",
"main",
"--limit",
"20",
"--json",
"workflowName,conclusion,status,headSha",
),
) as Run[];
lines.push(...runsIn(runs));
const open = JSON.parse(
gh("pr", "list", "-R", `${OWNER}/${repo}`, "--json", "number"),
) as unknown[];
lines.push({ what: "pull requests", found: `${open.length} open`, ok: true });
} catch (error) {
lines.push({
what: "GitHub",
found: `couldn't be read: ${(error as Error).message.split("\n")[0]}`,
ok: false,
});
}
if (!pkg.private && pkg.name) {
try {
const response = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`);
const latest = response.ok
? ((await response.json()) as { version: string }).version
: undefined;
lines.push(npmIn(latest, pkg.version));
} catch (error) {
lines.push({
what: "npm",
found: `couldn't be read: ${(error as Error).message}`,
ok: false,
});
}
}
if (!site) return { lines };
const login = cloudflareLogin();
if (login) {
lines.push({ what: "build", found: `not read, since ${login}`, ok: false });
return { lines: [...lines, ...(await addresses(site))] };
}
const found = workerTag(site.name);
if (!found) {
lines.push({ what: "build", found: `no Worker named ${site.name} on Cloudflare`, ok: false });
return { lines: [...lines, ...(await addresses(site))] };
}
const builds = cf("builds", "list", "--external-script-id", found.id) as Build[];
const build = builds[0];
lines.push(buildIn(build));
lines.push(...(await addresses(site, found.dev)));
return { lines, build: build?.build_uuid };
}
/** The lines of a build's log. */
export function buildLog(uuid: string): string[] {
const log = cf("builds", "logs", "get", uuid) as { lines: [number, string][] };
return log.lines.map(([, text]) => text);
}