Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

130 lines · 6.9 KBRaw History
1import { existsSync, readFileSync, realpathSync } from "node:fs";
2import { mkdir, rm, symlink } from "node:fs/promises";
3import { randomUUID } from "node:crypto";
4import { homedir } from "node:os";
5import path from "node:path";
6import { errMsg, failure } from "../job.js";
7import { hashFiles, inputList, maxSatisfying, satisfies, truthyInput, which } from "../util.js";
8import type { Builtin, BuiltinContext } from "./index.js";
9import { fetchJson, fetchToFile, packageCache, probe, readVersionFile, toolArch } from "./tools.js";
10
11interface PyManifestEntry {
12 version: string;
13 stable: boolean;
14 files: Array<{ filename: string; arch: string; platform: string; platform_version?: string; download_url: string }>;
15}
16
17const MANIFEST = "https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json";
18
19export const setupPython: Builtin = async (c) => {
20 let spec = c.input("python-version");
21 const file = c.input("python-version-file");
22 if (!spec && file) spec = await readVersionFile(c, file, "python");
23 if (!spec && existsSync(path.join(c.job.workspace, ".python-version"))) spec = await readVersionFile(c, ".python-version", "python");
24 spec = spec.trim();
25 if (/^pypy|graalpy/i.test(spec)) throw failure(`'${spec}' is not supported by the Diffinery setup-python built-in.`);
26 const env = c.toolEnv();
27 const installedRaw = await probe(c, "python3", ["--version"], env);
28 const installed = installedRaw?.replace(/^Python\s+/, "") ?? null;
29
30 let binDir: string;
31 let version: string;
32 const want = spec.replace(/-dev$/, "").replace(/\.x$/, "");
33 if (installed && (!spec || satisfies(installed, want))) {
34 c.log(`Using pre-installed Python ${installed}`);
35 const py3 = which("python3", env.PATH)!;
36 binDir = path.dirname(realpathSync(py3));
37 version = installed;
38 // make sure `python`/`pip` resolve to python3
39 if (!which("python", env.PATH) || !which("pip", env.PATH)) {
40 const shim = path.join(c.job.tempDir, "_python_shim");
41 await mkdir(shim, { recursive: true });
42 for (const [n, t] of [["python", "python3"], ["pip", "pip3"]] as const) {
43 const target = which(t, env.PATH);
44 const link = path.join(shim, n);
45 if (target && !existsSync(link)) await symlink(target, link).catch(() => {});
46 }
47 c.addPath(shim);
48 }
49 } else {
50 let manifest: PyManifestEntry[];
51 try {
52 manifest = await fetchJson<PyManifestEntry[]>(process.env.DIFFINERY_PYTHON_MANIFEST ?? MANIFEST);
53 } catch (e) {
54 throw failure(`Unable to fetch the Python versions manifest: ${errMsg(e)}`);
55 }
56 const arch = toolArch("python");
57 const candidates = manifest.filter((m) => m.files.some((f) => f.platform === "linux" && f.arch === arch));
58 let v = maxSatisfying(candidates.filter((m) => m.stable).map((m) => m.version), want);
59 // allow-prereleases (and `-dev` specs) fall back to pre-release builds when no stable one matches.
60 if (!v && (truthyInput(c.input("allow-prereleases"), false) || spec.endsWith("-dev"))) v = maxSatisfying(candidates.map((m) => m.version), want);
61 if (!v) throw failure(`The version '${spec}' with architecture '${arch}' was not found for this operating system.`);
62 const entry = candidates.find((m) => m.version === v)!;
63 const files = entry.files.filter((f) => f.platform === "linux" && f.arch === arch);
64 const f = files.find((x) => x.platform_version === "24.04") ?? files.find((x) => x.platform_version === "22.04") ?? files[0]!;
65 const installDir = path.join(c.job.toolCache, "Python", v, arch);
66 if (!existsSync(`${installDir}.complete`)) {
67 const work = path.join(c.job.tempDir, `python-${randomUUID()}`);
68 await mkdir(work, { recursive: true });
69 try {
70 c.log(`Downloading ${f.download_url}`);
71 const tarball = path.join(work, "python.tar.gz");
72 await fetchToFile(f.download_url, tarball);
73 await c.run("tar", ["-xzf", tarball, "-C", work], { silent: true });
74 c.log("Running python installer (setup.sh)");
75 await c.run("bash", ["./setup.sh"], { cwd: work, env: c.toolEnv({ RUNNER_TOOL_CACHE: c.job.toolCache, AGENT_TOOLSDIRECTORY: c.job.toolCache }) });
76 } finally {
77 await rm(work, { recursive: true, force: true });
78 }
79 }
80 binDir = path.join(installDir, "bin");
81 version = v;
82 c.addPath(installDir);
83 c.addPath(binDir);
84 c.exportVariable("pythonLocation", installDir);
85 c.exportVariable("Python_ROOT_DIR", installDir);
86 c.exportVariable("Python3_ROOT_DIR", installDir);
87 c.exportVariable("PKG_CONFIG_PATH", path.join(installDir, "lib", "pkgconfig"));
88 c.exportVariable("LD_LIBRARY_PATH", [path.join(installDir, "lib"), process.env.LD_LIBRARY_PATH].filter(Boolean).join(":"));
89 }
90 c.setOutput("python-version", version);
91 c.setOutput("python-path", path.join(binDir, existsSync(path.join(binDir, "python")) ? "python" : "python3"));
92
93 const cache = c.input("cache").toLowerCase();
94 if (cache) {
95 const ws = c.job.workspace;
96 const arch = process.arch;
97 const deps = c.input("cache-dependency-path");
98 if (cache === "pip") {
99 const hash = hashFiles(ws, inputList(deps || "**/requirements.txt")) || hashFiles(ws, ["**/pyproject.toml"]);
100 if (!hash) throw failure(`No file in ${ws} matched to [${inputList(deps || "**/requirements.txt").join(",")} or **/pyproject.toml], make sure you have checked out the target repository`);
101 let dir = await probe(c, "pip", ["cache", "dir"], c.toolEnv());
102 if (dir?.includes("~")) dir = path.join(homedir(), dir.slice(dir.indexOf("~") + 1));
103 const os = await linuxInfo(c);
104 const prefix = `setup-python-Linux-${arch}-${os.version}-${os.name}-python-${version}-pip`;
105 await packageCache(c, { key: `${prefix}-${hash}`, restoreKeys: [prefix], paths: dir ? [dir] : [], what: "pip" });
106 } else if (cache === "pipenv") {
107 const hash = hashFiles(ws, inputList(deps || "**/Pipfile.lock"));
108 if (!hash) throw failure(`No file in ${ws} matched to [${inputList(deps || "**/Pipfile.lock").join(",")}], make sure you have checked out the target repository`);
109 await packageCache(c, { key: `setup-python-Linux-${arch}-python-${version}-pipenv-${hash}`, paths: [path.join(homedir(), ".local/share/virtualenvs")], what: "pipenv" });
110 } else {
111 throw failure(`Caching for '${cache}' is not supported`);
112 }
113 }
114};
115
116/** setup-python's `lsb_release -i -r -s` (falls back to /etc/os-release). */
117async function linuxInfo(c: BuiltinContext): Promise<{ name: string; version: string }> {
118 const out = await probe(c, "lsb_release", ["-i", "-r", "-s"]);
119 if (out) {
120 const [name, version] = out.split("\n").map((s) => s.trim());
121 if (name && version) return { name, version };
122 }
123 try {
124 const rel = readFileSync("/etc/os-release", "utf8");
125 const get = (k: string) => new RegExp(`^${k}="?([^"\n]*)"?`, "m").exec(rel)?.[1] ?? "";
126 return { name: get("NAME").split(" ")[0] || "Linux", version: get("VERSION_ID") };
127 } catch {
128 return { name: "Linux", version: "" };
129 }
130}