Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions review-enrichment/src/analyzers/eol-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// End-of-life runtime regression analyzer (#1504). Parses runtime/base-image/engine version pins a PR changes
// (Dockerfile FROM, .nvmrc, go.mod) and checks endoflife.date (free, no key) — flagging a pin onto a release that
// is already past end-of-support or goes EOL within 90 days. The no-checkout reviewer has no EOL calendar; this does.
import type { EnrichRequest, EolFinding } from "../types.js";

// Docker image / source → endoflife.date product slug.
const DOCKER_PRODUCT: Record<string, string> = {
node: "nodejs",
python: "python",
golang: "go",
ruby: "ruby",
php: "php",
debian: "debian",
ubuntu: "ubuntu",
alpine: "alpine",
};

interface VersionPin {
file: string;
product: string;
version: string;
}

// Leading numeric version from a tag/value: "3.8-slim" → "3.8", "18" → "18", "latest" → null.
function leadingVersion(value: string): string | null {
return /^v?(\d+(?:\.\d+)*)/.exec(value.trim())?.[1] ?? null;
}

function isDockerfile(path: string): boolean {
const base = path.split("/").pop() ?? path;
return base === "Dockerfile" || /\.dockerfile$/i.test(base);
}

/** Pull (product, version) pins out of the added lines of changed Dockerfile / .nvmrc / go.mod. Pure. */
export function extractVersionPins(
files: NonNullable<EnrichRequest["files"]>,
): VersionPin[] {
const pins: VersionPin[] = [];
for (const file of files) {
if (!file.patch) continue;
const base = file.path.split("/").pop() ?? file.path;
for (const raw of file.patch.split("\n")) {
if (raw[0] !== "+" || raw.startsWith("+++")) continue;
const line = raw.slice(1).trim();
if (isDockerfile(file.path)) {
const match =
/^FROM\s+(?:--platform=\S+\s+)?([a-z0-9._/-]+):([a-zA-Z0-9._-]+)/i.exec(
line,
);
if (match) {
const product =
DOCKER_PRODUCT[(match[1]!.split("/").pop() ?? "").toLowerCase()];
const version = leadingVersion(match[2]!);
if (product && version)
pins.push({ file: file.path, product, version });
}
} else if (base === ".nvmrc") {
const version = leadingVersion(line);
if (version) pins.push({ file: file.path, product: "nodejs", version });
} else if (base === "go.mod") {
const match = /^go\s+(\d+\.\d+)/.exec(line);
if (match)
pins.push({ file: file.path, product: "go", version: match[1]! });
}
}
}
return pins;
}

interface Cycle {
cycle: string;
eol: string | boolean;
}

// Match a version to its release cycle — most specific (longest) cycle prefix wins (so "18.17" → "18", "3.8" → "3.8").
function matchCycle(cycles: Cycle[], version: string): Cycle | undefined {
const sorted = [...cycles].sort((a, b) => b.cycle.length - a.cycle.length);
return (
sorted.find(
(c) => version === c.cycle || version.startsWith(c.cycle + "."),
) ?? sorted.find((c) => version.split(".")[0] === c.cycle)
);
}

function eolStatus(
eol: string | boolean,
now: number,
): EolFinding["status"] | null {
if (eol === false) return null;
if (eol === true) return "eol";
const eolMs = new Date(eol).getTime();
if (!Number.isFinite(eolMs)) return null;
if (eolMs < now) return "eol";
if (eolMs < now + 90 * 86_400_000) return "soon";
return null;
}

async function fetchCycles(
product: string,
fetchImpl: typeof fetch,
): Promise<Cycle[] | null> {
const response = await fetchImpl(
`https://endoflife.date/api/${product}.json`,
);
if (!response.ok) return null;
const data = (await response.json()) as Cycle[];
return Array.isArray(data) ? data : null;
}

/** Analyzer entrypoint: changed runtime pins → endoflife.date → only the EOL / EOL-soon ones. `now` injectable. */
export async function scanEol(
req: EnrichRequest,
fetchImpl: typeof fetch = fetch,
now: number = Date.now(),
): Promise<EolFinding[]> {
const findings: EolFinding[] = [];
const seen = new Set<string>();
for (const pin of extractVersionPins(req.files ?? [])) {
const key = `${pin.product}:${pin.version}`;
if (seen.has(key)) continue;
seen.add(key);
const cycles = await fetchCycles(pin.product, fetchImpl);
if (!cycles) continue;
const cycle = matchCycle(cycles, pin.version);
if (!cycle) continue;
const status = eolStatus(cycle.eol, now);
if (status)
findings.push({
file: pin.file,
product: pin.product,
version: pin.version,
eol: String(cycle.eol),
status,
});
}
return findings;
}
2 changes: 2 additions & 0 deletions review-enrichment/src/brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { scanSecrets } from "./analyzers/secret-scan.js";
import { scanLicenses } from "./analyzers/license-check.js";
import { scanInstallScripts } from "./analyzers/install-scripts.js";
import { scanActionPins } from "./analyzers/actions-pin.js";
import { scanEol } from "./analyzers/eol-check.js";
import { renderBrief } from "./render.js";

type AnalyzerFn = (req: EnrichRequest) => Promise<unknown>;
Expand All @@ -23,6 +24,7 @@ const ANALYZERS: Record<keyof BriefFindings, AnalyzerFn> = {
license: (req) => scanLicenses(req),
installScript: (req) => scanInstallScripts(req),
actionPin: (req) => scanActionPins(req),
eol: (req) => scanEol(req),
};

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
Expand Down
11 changes: 11 additions & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ export function renderBrief(
}
}

const eol = findings.eol ?? [];
if (eol.length) {
lines.push("### End-of-life runtimes (upgrade before merging)");
for (const item of eol) {
const label = item.status === "eol" ? "END-OF-LIFE" : "EOL soon";
lines.push(
`- \`${item.file}\` pins ${item.product} ${item.version} — **${label}** (EOL ${item.eol})`,
);
}
}

if (!lines.length) return { promptSection: "", systemSuffix: "" };

const header =
Expand Down
10 changes: 10 additions & 0 deletions review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,23 @@ export interface ActionPinFinding {
ref: string;
}

/** A runtime/base-image/engine pinned to a release that is past end-of-support (or EOL within 90 days). */
export interface EolFinding {
file: string;
product: string;
version: string;
eol: string;
status: "eol" | "soon";
}

/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */
export interface BriefFindings {
dependency?: DependencyFinding[];
secret?: SecretFinding[];
license?: LicenseFinding[];
actionPin?: ActionPinFinding[];
installScript?: InstallScriptFinding[];
eol?: EolFinding[];
}

export type AnalyzerStatus = "ok" | "degraded" | "skipped";
Expand Down
98 changes: 98 additions & 0 deletions review-enrichment/test/enrichment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ import {
scanWorkflowPins,
scanActionPins,
} from "../dist/analyzers/actions-pin.js";
import { scanEol, extractVersionPins } from "../dist/analyzers/eol-check.js";

const NOW = new Date("2026-06-26").getTime();
const eolFetch =
(cycles, ok = true) =>
async () => ({ ok, json: async () => cycles });
const dockerfilePatch = (tag) => ({
repoFullName: "o/r",
prNumber: 1,
files: [{ path: "Dockerfile", patch: `@@ -1,0 +1,1 @@\n+FROM node:${tag}` }],
});

const npmFetch =
(scripts, time = {}) =>
Expand Down Expand Up @@ -469,3 +480,90 @@ test("buildBrief: action-pin analyzer runs (pure, no network)", async () => {
globalThis.fetch = realFetch;
}
});

test("extractVersionPins: Dockerfile FROM + .nvmrc + go.mod; latest skipped", () => {
const pins = extractVersionPins([
{
path: "Dockerfile",
patch: "@@ -1,0 +1,2 @@\n+FROM python:3.8-slim\n+FROM node:latest",
},
{ path: ".nvmrc", patch: "@@ -1,0 +1,1 @@\n+v18.17.0" },
{ path: "go.mod", patch: "@@ -1,0 +1,1 @@\n+go 1.20" },
]);
const byProduct = Object.fromEntries(pins.map((p) => [p.product, p]));
assert.equal(byProduct.python.version, "3.8");
assert.equal(byProduct.nodejs.version, "18.17.0");
assert.equal(byProduct.go.version, "1.20");
assert.ok(
!pins.some((p) => p.product === "nodejs" && p.file === "Dockerfile"),
); // node:latest skipped
});

test("scanEol: flags EOL + EOL-soon, skips current + fetch-fail (injected now)", async () => {
const cycles = [
{ cycle: "18", eol: "2023-06-01" },
{ cycle: "20", eol: "2026-07-01" },
{ cycle: "22", eol: "2027-04-30" },
{ cycle: "24", eol: false },
];
const fetchImpl = eolFetch(cycles);
assert.equal(
(await scanEol(dockerfilePatch("18"), fetchImpl, NOW))[0].status,
"eol",
);
assert.equal(
(await scanEol(dockerfilePatch("20"), fetchImpl, NOW))[0].status,
"soon",
);
assert.equal(
(await scanEol(dockerfilePatch("22"), fetchImpl, NOW)).length,
0,
);
assert.equal(
(await scanEol(dockerfilePatch("24"), fetchImpl, NOW)).length,
0,
); // eol:false
assert.equal(
(await scanEol(dockerfilePatch("18"), eolFetch([], false), NOW)).length,
0,
);
});

test("renderBrief: renders the EOL block", () => {
const r = renderBrief({
eol: [
{
file: "Dockerfile",
product: "nodejs",
version: "18",
eol: "2023-06-01",
status: "eol",
},
],
});
assert.match(r.promptSection, /End-of-life runtimes/);
assert.match(
r.promptSection,
/pins nodejs 18 — \*\*END-OF-LIFE\*\* \(EOL 2023-06-01\)/,
);
});

test("buildBrief: eol analyzer runs (real now, 2023 cycle is past)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) =>
String(url).includes("endoflife.date")
? { ok: true, json: async () => [{ cycle: "18", eol: "2023-06-01" }] }
: { ok: true, json: async () => ({}) };
try {
const brief = await buildBrief({
repoFullName: "o/r",
prNumber: 1,
files: [{ path: "Dockerfile", patch: "@@ -1,0 +1,1 @@\n+FROM node:18" }],
});
assert.equal(brief.analyzerStatus.eol, "ok");
assert.equal(brief.findings.eol.length, 1);
assert.match(brief.promptSection, /End-of-life runtimes/);
} finally {
globalThis.fetch = realFetch;
}
});