Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4d899119 | ||
|
|
9fcc59fbc9 |
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
# CI-only image for the dependency check.
|
||||||
|
#
|
||||||
|
# The report is a Node script because it has to compare semantic versions and
|
||||||
|
# read four different HTTP APIs, and the official `rust` image ships no Node —
|
||||||
|
# while the `node` image ships no cargo, which `cargo metadata` needs to resolve
|
||||||
|
# the workspace graph. Rather than maintain the parsing twice in two languages,
|
||||||
|
# this layers the two together.
|
||||||
|
#
|
||||||
|
# Node is copied from the official image rather than installed from a
|
||||||
|
# third-party apt repository: no extra signing key, no extra network
|
||||||
|
# dependency at build time, and the same binaries everyone else runs.
|
||||||
|
ARG RUST_VERSION=1.95
|
||||||
|
ARG NODE_VERSION=22-bookworm-slim
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION} AS node
|
||||||
|
|
||||||
|
FROM rust:${RUST_VERSION}-bookworm
|
||||||
|
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||||
|
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||||
|
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||||
|
&& node --version
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
//! Report every dependency that has a newer release than the one in use.
|
||||||
|
//!
|
||||||
|
//! Runs from `.gitea/workflows/dependency-check.yml` on a six-hour schedule.
|
||||||
|
//! Prints a Markdown report on stdout and exits 0 whether or not anything is
|
||||||
|
//! out of date — "up to date" is a normal outcome, not a failure. The workflow
|
||||||
|
//! decides what to do with the report; this file only gathers facts.
|
||||||
|
//!
|
||||||
|
//! Three things are checked, because all three go stale independently and none
|
||||||
|
//! of them is visible from the others:
|
||||||
|
//!
|
||||||
|
//! * **Crates** — every locked dependency in `Cargo.lock` against crates.io.
|
||||||
|
//! * **The Rust pin** — `rust-toolchain.toml`'s channel against the latest
|
||||||
|
//! stable release, and the Dockerfile's `RUST_VERSION` against both, since
|
||||||
|
//! those two drifting apart is how a local build and a shipped image end up
|
||||||
|
//! on different compilers.
|
||||||
|
//! * **Base images** — the Dockerfile's `FROM` tags against their upstream
|
||||||
|
//! registries.
|
||||||
|
//!
|
||||||
|
//! Network only: no crates are downloaded, nothing is built, and the working
|
||||||
|
//! tree is never modified.
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
const UA = "dependency-check (+https://gitea.milesward.dev/mward4)";
|
||||||
|
|
||||||
|
// ── version comparison ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parse `1.2.3`, `1.2`, `1.2.3-alpha.1`, and `=1.2.3` into a comparable
|
||||||
|
/// tuple. Returns null for anything that isn't a version we understand, so an
|
||||||
|
/// unparseable requirement is reported as "unknown" rather than silently
|
||||||
|
/// sorting to the top.
|
||||||
|
function parseVersion(text) {
|
||||||
|
const m = /^[=~^]?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+](.*))?$/.exec(text.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
major: Number(m[1]),
|
||||||
|
minor: Number(m[2] ?? 0),
|
||||||
|
patch: Number(m[3] ?? 0),
|
||||||
|
pre: m[4] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order two parsed versions. A pre-release sorts below its release, so
|
||||||
|
/// `2.0.0-rc.1` never displaces a stable `1.9.0` as "the newest".
|
||||||
|
function compare(a, b) {
|
||||||
|
for (const key of ["major", "minor", "patch"]) {
|
||||||
|
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (a.pre === b.pre) return 0;
|
||||||
|
if (!a.pre) return 1;
|
||||||
|
if (!b.pre) return -1;
|
||||||
|
return a.pre < b.pre ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a version difference is allowed to be taken.
|
||||||
|
///
|
||||||
|
/// The pre-1.0 rule is the one that matters here: semver treats `0.x.y` as
|
||||||
|
/// unstable, so `0.5 → 0.6` breaks exactly as hard as `1.5 → 2.0`. Getting this
|
||||||
|
/// wrong would label half the RustCrypto family "minor or patch" when in fact
|
||||||
|
/// `cargo update` will refuse every one of them.
|
||||||
|
function bumpKind(from, to) {
|
||||||
|
if (to.major !== from.major) return "breaking";
|
||||||
|
if (from.major === 0 && to.minor !== from.minor) return "breaking";
|
||||||
|
return "compatible";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest release reached without crossing a breaking boundary — what
|
||||||
|
/// `cargo update` will move to on its own.
|
||||||
|
function bestCompatible(from, versions) {
|
||||||
|
let best = null;
|
||||||
|
for (const raw of versions) {
|
||||||
|
const v = parseVersion(raw);
|
||||||
|
// `bumpKind` is checked before comparing against `from`: `0.14.7` is not
|
||||||
|
// semver-compatible with `1.0.0` even though it is numerically lower, and
|
||||||
|
// the naive `compare > 0` test would let `0.14 → 1.4` through as routine.
|
||||||
|
if (!v || bumpKind(from, v) !== "compatible" || compare(v, from) <= 0) continue;
|
||||||
|
if (!best || compare(v, best) > 0) best = v;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest release of any kind, which for a breaking bump is what a human
|
||||||
|
/// would have to adopt.
|
||||||
|
function bestOverall(from, versions) {
|
||||||
|
let best = null;
|
||||||
|
for (const raw of versions) {
|
||||||
|
const v = parseVersion(raw);
|
||||||
|
if (!v || compare(v, from) <= 0) continue;
|
||||||
|
if (!best || compare(v, best) > 0) best = v;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v) => (v ? `${v.major}.${v.minor}.${v.patch}${v.pre ? `-${v.pre}` : ""}` : "?");
|
||||||
|
|
||||||
|
// ── inputs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Resolve the dependency graph with cargo itself.
|
||||||
|
///
|
||||||
|
/// `--locked` rather than a hand-rolled Cargo.lock parse: it fails instead of
|
||||||
|
/// silently rewriting the lockfile, which matters because this runs on a
|
||||||
|
/// schedule and must never mutate the repository it is reporting on. Cargo.toml
|
||||||
|
/// is still read directly below for the declared requirement, since metadata
|
||||||
|
/// reports the resolved version rather than the constraint.
|
||||||
|
function cargoMetadata() {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = execFileSync("cargo", ["metadata", "--format-version", "1", "--locked"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// A cargo failure here means no report at all. Say why in one line rather
|
||||||
|
// than letting a raw stack trace stand in for an explanation.
|
||||||
|
const detail = (e.stderr || e.message || "").toString().trim().split("\n")[0];
|
||||||
|
throw new Error(`cargo metadata failed: ${detail}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredRequirements() {
|
||||||
|
// Handles the `dep = "1.2"` and `dep = { version = "1.2", ... }` forms and
|
||||||
|
// nothing else, which is every dependency this workspace declares.
|
||||||
|
const text = readFileSync("Cargo.toml", "utf8");
|
||||||
|
const out = new Map();
|
||||||
|
let section = "";
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const header = /^\[([^\]]+)\]/.exec(line);
|
||||||
|
if (header) {
|
||||||
|
section = header[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!/dependencies/.test(section)) continue;
|
||||||
|
const m = /^\s*([A-Za-z0-9_-]+)\s*=\s*(.+?)\s*$/.exec(line);
|
||||||
|
if (!m) continue;
|
||||||
|
const [, name, value] = m;
|
||||||
|
const quoted = /^"([^"]+)"/.exec(value);
|
||||||
|
if (quoted) out.set(name, quoted[1]);
|
||||||
|
else {
|
||||||
|
const v = /version\s*=\s*"([^"]+)"/.exec(value);
|
||||||
|
if (v) out.set(name, v[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── external lookups ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The latest stable Rust, from the release channel manifest.
|
||||||
|
///
|
||||||
|
/// Read from the sparse index rather than the crates.io API. The API is rate
|
||||||
|
/// limited and answers 429 under a burst of ~130 lookups, which silently
|
||||||
|
/// turned most of this report into "could not check" on the first run; the
|
||||||
|
/// index is the same data, one request per crate, no limit and no user agent
|
||||||
|
/// requirement.
|
||||||
|
async function publishedVersions(name) {
|
||||||
|
// The index shards by name length and case: 1-char names live under "1",
|
||||||
|
// 2-char under "2", 3-char under "3/<first letter>", and everything else
|
||||||
|
// under the first two characters. Names are lowercased on the way in.
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
const prefix =
|
||||||
|
lower.length === 1
|
||||||
|
? `1/${lower}`
|
||||||
|
: lower.length === 2
|
||||||
|
? `2/${lower}`
|
||||||
|
: lower.length === 3
|
||||||
|
? `3/${lower[0]}/${lower}`
|
||||||
|
: `${lower.slice(0, 2)}/${lower.slice(2, 4)}/${lower}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://index.crates.io/${prefix}`, {
|
||||||
|
headers: { "user-agent": UA },
|
||||||
|
});
|
||||||
|
// The index answers 404 for a crate that does not exist and 410 for one
|
||||||
|
// that was removed; both mean "nothing to compare against".
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.text())
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
.filter((entry) => !entry.yanked && !entry.vers.includes("-"))
|
||||||
|
.map((entry) => entry.vers);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest stable Rust, from the release channel manifest.
|
||||||
|
async function latestRust() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://static.rust-lang.org/dist/channel-rust-stable.toml", {
|
||||||
|
headers: { "user-agent": UA },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const m = /\[pkg\.rust\]\nversion = "([^"]+)"/.exec(await res.text());
|
||||||
|
// The manifest value carries the commit: "1.98.1 (48a229cea 2026-09-01)".
|
||||||
|
// Only the number is comparable to the Dockerfile's RUST_VERSION.
|
||||||
|
return m ? m[1].split(/\s+/)[0] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags for a Docker Hub repository, or null if it can't be read.
|
||||||
|
async function dockerTags(repo) {
|
||||||
|
const [ns, name] = repo.includes("/") ? repo.split("/") : ["library", repo];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://hub.docker.com/v2/repositories/${ns}/${name}/tags?page_size=100`, {
|
||||||
|
headers: { "user-agent": UA },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data.results.map((t) => t.name);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── checks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function checkCrates() {
|
||||||
|
const meta = cargoMetadata();
|
||||||
|
const requirements = declaredRequirements();
|
||||||
|
|
||||||
|
// One row per crate actually compiled into the workspace. Dev-dependencies
|
||||||
|
// and build-dependencies are real dependencies too, so they are included —
|
||||||
|
// a stale build script is still a stale build script.
|
||||||
|
const seen = new Map();
|
||||||
|
for (const pkg of meta.packages) {
|
||||||
|
if (meta.workspace_members.includes(pkg.id)) continue;
|
||||||
|
seen.set(pkg.name, { from: pkg.version, kind: pkg.source ? "registry" : "path" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const [name, info] of [...seen].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||||
|
if (info.kind === "path") continue;
|
||||||
|
|
||||||
|
// Direct means "named in Cargo.toml", which is the only set the author can
|
||||||
|
// act on. The rest move when whatever pulls them in moves, so they are
|
||||||
|
// reported but never counted as work.
|
||||||
|
const direct = requirements.has(name);
|
||||||
|
|
||||||
|
const current = parseVersion(info.from);
|
||||||
|
const versions = await publishedVersions(name);
|
||||||
|
if (!current || !versions) {
|
||||||
|
rows.push({ name, from: info.from, to: null, kind: "unknown", direct, note: "could not check" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compatible = bestCompatible(current, versions);
|
||||||
|
const overall = bestOverall(current, versions);
|
||||||
|
if (!overall) continue; // already newest
|
||||||
|
|
||||||
|
// `compatible` is what `cargo update` will do unaided. If the newest
|
||||||
|
// release is further out than that, the gap is a breaking change someone
|
||||||
|
// has to adopt deliberately.
|
||||||
|
const kind = compatible ? "compatible" : bumpKind(current, overall);
|
||||||
|
rows.push({
|
||||||
|
name,
|
||||||
|
from: info.from,
|
||||||
|
to: fmt(overall),
|
||||||
|
kind,
|
||||||
|
direct,
|
||||||
|
note: kind === "compatible" ? "`cargo update`" : "**breaking — needs review**",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rustPin() {
|
||||||
|
const toolchain = existsSync("rust-toolchain.toml")
|
||||||
|
? readFileSync("rust-toolchain.toml", "utf8")
|
||||||
|
: "";
|
||||||
|
const channel = /channel\s*=\s*"([^"]+)"/.exec(toolchain)?.[1] ?? null;
|
||||||
|
|
||||||
|
// The image bakes its own toolchain, so this is the number that decides what
|
||||||
|
// actually compiles the release binary.
|
||||||
|
const dockerfile = existsSync("deploy/docker/Dockerfile")
|
||||||
|
? readFileSync("deploy/docker/Dockerfile", "utf8")
|
||||||
|
: existsSync("Dockerfile")
|
||||||
|
? readFileSync("Dockerfile", "utf8")
|
||||||
|
: "";
|
||||||
|
const image = /ARG\s+RUST_VERSION=([0-9.]+)/.exec(dockerfile)?.[1] ?? null;
|
||||||
|
|
||||||
|
// The number `cargo` enforces as the minimum supported toolchain.
|
||||||
|
const manifest = readFileSync("Cargo.toml", "utf8");
|
||||||
|
const msrv = /rust-version\s*=\s*"([^"]+)"/.exec(manifest)?.[1] ?? null;
|
||||||
|
|
||||||
|
return { channel, image, msrv };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `FROM` in the Dockerfile, with the `ARG` substitution applied for the
|
||||||
|
/// one argument this build actually parameterises.
|
||||||
|
function baseImages() {
|
||||||
|
const path = existsSync("deploy/docker/Dockerfile")
|
||||||
|
? "deploy/docker/Dockerfile"
|
||||||
|
: existsSync("Dockerfile")
|
||||||
|
? "Dockerfile"
|
||||||
|
: null;
|
||||||
|
if (!path) return [];
|
||||||
|
|
||||||
|
const text = readFileSync(path, "utf8");
|
||||||
|
const args = new Map(
|
||||||
|
[...text.matchAll(/^ARG\s+([A-Za-z0-9_]+)=(\S+)/gm)].map((m) => [m[1], m[2]]),
|
||||||
|
);
|
||||||
|
const images = [];
|
||||||
|
for (const m of text.matchAll(/^FROM\s+(?:--platform=\S+\s+)?(\S+)/gm)) {
|
||||||
|
let ref = m[1];
|
||||||
|
// `node:${NODE_VERSION}-slim` and friends.
|
||||||
|
ref = ref.replace(/\$\{?([A-Za-z0-9_]+)\}?/g, (whole, name) => args.get(name) ?? whole);
|
||||||
|
if (ref.includes("$")) continue; // still unresolved; skip rather than guess
|
||||||
|
const [repo, tag = "latest"] = ref.split(":");
|
||||||
|
images.push({ repo, tag });
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRustToolchain() {
|
||||||
|
const pin = rustPin();
|
||||||
|
const latest = await latestRust();
|
||||||
|
return { ...pin, latest };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest tag that keeps the variant suffix the Dockerfile asked for.
|
||||||
|
///
|
||||||
|
/// `rust:1.95-bookworm` and `node:22-bookworm-slim` are not interchangeable
|
||||||
|
/// with their plain counterparts, so a tag is only a candidate if it ends in
|
||||||
|
/// exactly the suffix already in use.
|
||||||
|
function bestTagFor(currentTag, tags) {
|
||||||
|
const m = /^([\d.]+)(-.*)?$/.exec(currentTag);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, , suffix = ""] = m;
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (suffix) {
|
||||||
|
if (!tag.endsWith(suffix)) continue;
|
||||||
|
} else if (!/^[\d.]+$/.test(tag)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const v = parseVersion(suffix ? tag.slice(0, -suffix.length) : tag);
|
||||||
|
// Date-stamped tags like `alpine:20260805` parse as versions but are edge
|
||||||
|
// or snapshot builds, not releases. No official image we track is anywhere
|
||||||
|
// near a three-digit major.
|
||||||
|
if (v && !suffix && v.major >= 100) continue;
|
||||||
|
if (v && (!best || compare(v, best) > 0)) best = v;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkImages() {
|
||||||
|
const rows = [];
|
||||||
|
for (const { repo, tag } of baseImages()) {
|
||||||
|
const tags = await dockerTags(repo);
|
||||||
|
if (!tags) {
|
||||||
|
rows.push({ repo, from: tag, to: null, note: "could not check" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseVersion(tag);
|
||||||
|
const best = bestTagFor(tag, tags);
|
||||||
|
if (!parsed || !best) {
|
||||||
|
rows.push({ repo, from: tag, to: null, note: "tag is not a version" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A base image major bump is a whole new distro release; same rule as a
|
||||||
|
// crate, so it is reported but flagged rather than treated as routine.
|
||||||
|
const bump = bumpKind(parsed, best);
|
||||||
|
rows.push({
|
||||||
|
repo,
|
||||||
|
from: tag,
|
||||||
|
to: fmt(best),
|
||||||
|
note: bump === "compatible" ? "" : "**breaking — needs review**",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── report ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function table(rows, columns) {
|
||||||
|
const head = `| ${columns.map((c) => c.title).join(" | ")} |`;
|
||||||
|
const rule = `| ${columns.map(() => "---").join(" | ")} |`;
|
||||||
|
const body = rows.map((r) => `| ${columns.map((c) => c.get(r)).join(" | ")} |`);
|
||||||
|
return [head, rule, ...body].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [crates, rust, images] = await Promise.all([checkCrates(), checkRustToolchain(), checkImages()]);
|
||||||
|
|
||||||
|
// Split by who can act. Only a crate named in Cargo.toml can be moved by
|
||||||
|
// hand; everything else follows whichever direct dependency pulls it in, so
|
||||||
|
// listing those as tasks would be noise that trains the reader to ignore the
|
||||||
|
// report.
|
||||||
|
const direct = crates.filter((r) => r.direct);
|
||||||
|
const transitive = crates.filter((r) => !r.direct);
|
||||||
|
const routine = direct.filter((r) => r.kind === "compatible");
|
||||||
|
const breaking = direct.filter((r) => r.kind === "breaking");
|
||||||
|
const rustOutdated = Boolean(rust.latest && rust.image && rust.image !== rust.latest);
|
||||||
|
// Transitive crates held back by a version boundary upstream has not crossed.
|
||||||
|
// Worth knowing, not worth acting on.
|
||||||
|
const heldBack = transitive.filter((r) => r.kind === "breaking");
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
const actionable = routine.length + breaking.length + images.length + (rustOutdated ? 1 : 0);
|
||||||
|
parts.push(
|
||||||
|
actionable === 0
|
||||||
|
? "Everything you can act on is current."
|
||||||
|
: `${routine.length} routine bump${routine.length === 1 ? "" : "s"}, ` +
|
||||||
|
`${breaking.length} breaking change${breaking.length === 1 ? "" : "s"}` +
|
||||||
|
(images.length ? `, ${images.length} base image${images.length === 1 ? "" : "s"}` : "") +
|
||||||
|
(rustOutdated ? ", and a newer Rust" : "") +
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
if (heldBack.length) {
|
||||||
|
parts.push(
|
||||||
|
`_A further ${heldBack.length} transitive crates have newer releases blocked behind a ` +
|
||||||
|
"breaking boundary upstream has not crossed yet. They are omitted below because " +
|
||||||
|
"nothing here can move them._",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routine.length) {
|
||||||
|
parts.push(
|
||||||
|
"### Direct dependencies — `cargo update` covers these\n\n" +
|
||||||
|
table(routine, [
|
||||||
|
{ title: "Crate", get: (r) => `\`${r.name}\`` },
|
||||||
|
{ title: "In use", get: (r) => `\`${r.from}\`` },
|
||||||
|
{ title: "Latest", get: (r) => `\`${r.to}\`` },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breaking.length) {
|
||||||
|
parts.push(
|
||||||
|
"### Direct dependencies — breaking, a human has to decide\n\n" +
|
||||||
|
"These cross a version boundary where semver allows anything to change. " +
|
||||||
|
"`cargo update` will not touch them: `Cargo.toml` needs editing and the " +
|
||||||
|
"changelog reading.\n\n" +
|
||||||
|
table(breaking, [
|
||||||
|
{ title: "Crate", get: (r) => `\`${r.name}\`` },
|
||||||
|
{ title: "In use", get: (r) => `\`${r.from}\`` },
|
||||||
|
{ title: "Latest", get: (r) => `\`${r.to}\`` },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rustRows = rust.latest
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
what: "Dockerfile `RUST_VERSION`",
|
||||||
|
using: rust.image ?? "not pinned",
|
||||||
|
latest: rust.latest,
|
||||||
|
note: rustOutdated ? "**the shipped binary is built by an older compiler**" : "current",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
what: "`rust-toolchain.toml` channel",
|
||||||
|
using: rust.channel ?? "unknown",
|
||||||
|
latest: rust.latest,
|
||||||
|
note:
|
||||||
|
rust.channel === "stable"
|
||||||
|
? "tracks stable, so it is already current"
|
||||||
|
: "pinned to a fixed release",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
what: "latest stable Rust",
|
||||||
|
using: rust.image ?? "unknown",
|
||||||
|
latest: "—",
|
||||||
|
note: "could not reach static.rust-lang.org",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
parts.push(
|
||||||
|
"### Rust\n\n" +
|
||||||
|
table(rustRows, [
|
||||||
|
{ title: "What", get: (r) => r.what },
|
||||||
|
{ title: "Declared", get: (r) => `\`${r.using}\`` },
|
||||||
|
{ title: "Latest", get: (r) => `\`${r.latest}\`` },
|
||||||
|
{ title: "Note", get: (r) => r.note },
|
||||||
|
]) +
|
||||||
|
(rust.msrv ? `\n\nMinimum supported Rust version: \`${rust.msrv}\`.` : ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (images.length) {
|
||||||
|
parts.push(
|
||||||
|
"### Base images\n\n" +
|
||||||
|
table(images, [
|
||||||
|
{ title: "Image", get: (r) => `\`${r.repo}\`` },
|
||||||
|
{ title: "In use", get: (r) => `\`${r.from}\`` },
|
||||||
|
{ title: "Latest", get: (r) => `\`${r.to}\`` },
|
||||||
|
{ title: "Note", get: (r) => r.note },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push("_Checked every 6 hours by `.gitea/workflows/dependency-check.yml`._");
|
||||||
|
|
||||||
|
console.log(parts.join("\n\n"));
|
||||||
|
// Machine-readable footer, so the workflow never has to parse prose.
|
||||||
|
console.error(
|
||||||
|
`::dep-check:: routine=${routine.length} breaking=${breaking.length} ` +
|
||||||
|
`images=${images.length} rust=${rustOutdated ? 1 : 0}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} catch (e) {
|
||||||
|
// Print the reason and exit non-zero. The workflow treats a missing report as
|
||||||
|
// a broken check rather than a clean bill of health, so this keeps the
|
||||||
|
// tracking issue open instead of closing it on no evidence.
|
||||||
|
console.error(`dependency check failed: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
name: Check for dependency updates
|
||||||
|
|
||||||
|
# Runs every six hours and reports anything out of date in one place: the
|
||||||
|
# crates in Cargo.lock, the Rust pin in rust-toolchain.toml and the Dockerfile,
|
||||||
|
# and the Dockerfile's base images.
|
||||||
|
#
|
||||||
|
# Everything lands in ONE issue titled "Dependency updates", edited in place
|
||||||
|
# rather than commented on, so there is a single current answer instead of a
|
||||||
|
# pile of history. When nothing is out of date the issue is closed — an empty
|
||||||
|
# issue left open is a thing people stop reading, which defeats the point.
|
||||||
|
#
|
||||||
|
# Nothing is upgraded automatically. `cargo update` on a schedule would land
|
||||||
|
# unreviewed code in a service that holds the operator's second factor, and a
|
||||||
|
# breaking bump needs a human regardless.
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Every six hours, off the hour so this doesn't compete with an image
|
||||||
|
# rebuild for a runner.
|
||||||
|
- cron: '23 */6 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# One run at a time. A manual dispatch during a scheduled run would otherwise
|
||||||
|
# race it and could open the issue twice.
|
||||||
|
concurrency:
|
||||||
|
group: dependency-check
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The toolchain comes from the repo rather than being hard-coded here, so
|
||||||
|
# the check runs on the same compiler the image ships with and this file
|
||||||
|
# can be copied between repos unchanged.
|
||||||
|
- name: Resolve the pinned Rust version
|
||||||
|
id: rust
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
FILE=deploy/docker/Dockerfile
|
||||||
|
[ -f "$FILE" ] || FILE=Dockerfile
|
||||||
|
VERSION=$(grep -E '^ARG RUST_VERSION=' "$FILE" | head -1 | cut -d= -f2)
|
||||||
|
[ -n "$VERSION" ] || { echo "::error::no ARG RUST_VERSION in $FILE"; exit 1; }
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "checking with rust ${VERSION}"
|
||||||
|
|
||||||
|
# The workspace is streamed into the container over stdin rather than
|
||||||
|
# bind-mounted, so this works whether the runner shares a filesystem with
|
||||||
|
# the daemon or not.
|
||||||
|
#
|
||||||
|
# The image is rust + node, built from .gitea/scripts/ci.Dockerfile: the
|
||||||
|
# report needs cargo to resolve the workspace graph and Node to read the
|
||||||
|
# registries, and neither official image has both.
|
||||||
|
#
|
||||||
|
# RUSTUP_TOOLCHAIN overrides rust-toolchain.toml: the image's own
|
||||||
|
# toolchain is enough to read Cargo.lock, and honouring the file would
|
||||||
|
# make every run fetch a second compiler for no benefit.
|
||||||
|
#
|
||||||
|
# Cargo is deliberately *not* run offline: at least one project here
|
||||||
|
# depends on a crate published to a private registry, and `--offline`
|
||||||
|
# turns that into a failed resolution and therefore no report at all.
|
||||||
|
- name: Generate report
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
VERSION="${{ steps.rust.outputs.version }}"
|
||||||
|
docker build \
|
||||||
|
--build-arg "RUST_VERSION=${VERSION}" \
|
||||||
|
--tag "dep-check:${VERSION}" \
|
||||||
|
--file .gitea/scripts/ci.Dockerfile \
|
||||||
|
.gitea/scripts > /dev/null
|
||||||
|
|
||||||
|
tar cf - --exclude=./.git --exclude=./target . | docker run --rm -i \
|
||||||
|
-e RUSTUP_TOOLCHAIN="${VERSION}" \
|
||||||
|
-e CARGO_TERM_COLOR=never \
|
||||||
|
"dep-check:${VERSION}" \
|
||||||
|
sh -ec 'mkdir -p /work && cd /work && tar xf - && exec node .gitea/scripts/dependency-report.mjs' \
|
||||||
|
> /tmp/report.md 2> /tmp/report.err || {
|
||||||
|
echo "::error::the dependency report failed to generate"
|
||||||
|
cat /tmp/report.err
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cat /tmp/report.md
|
||||||
|
grep -E '::dep-check::' /tmp/report.err || true
|
||||||
|
|
||||||
|
# An empty report is a broken check, not a clean bill of health.
|
||||||
|
# Failing here keeps the tracking issue open rather than closing it on
|
||||||
|
# the strength of a file that never got written.
|
||||||
|
test -s /tmp/report.md
|
||||||
|
|
||||||
|
grep -oE 'routine=[0-9]+ breaking=[0-9]+ images=[0-9]+ rust=[0-9]+' /tmp/report.err \
|
||||||
|
> /tmp/counts
|
||||||
|
cat /tmp/counts
|
||||||
|
|
||||||
|
- name: Open, update or close the tracking issue
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
API="https://gitea.milesward.dev/api/v1"
|
||||||
|
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const report = fs.readFileSync("/tmp/report.md", "utf8").trim();
|
||||||
|
const counts = fs.readFileSync("/tmp/counts", "utf8").trim();
|
||||||
|
const [routine, breaking, images, rust] = counts.match(/[0-9]+/g).map(Number);
|
||||||
|
const actionable = routine + breaking + images + rust;
|
||||||
|
|
||||||
|
const body =
|
||||||
|
(actionable === 0
|
||||||
|
? "Everything tracked is current. Closing this until something changes.\n\n"
|
||||||
|
: "") +
|
||||||
|
report +
|
||||||
|
"\n\n---\n\n" +
|
||||||
|
"Rewritten by `.gitea/workflows/dependency-check.yml` every 6 hours, so this is " +
|
||||||
|
"always the latest report rather than a pile of comments.";
|
||||||
|
|
||||||
|
fs.writeFileSync("/tmp/state.json", JSON.stringify({ actionable, body }));
|
||||||
|
'
|
||||||
|
|
||||||
|
ACTIONABLE=$(node -e 'process.stdout.write(String(require("/tmp/state.json").actionable))')
|
||||||
|
echo "actionable=$ACTIONABLE"
|
||||||
|
|
||||||
|
# Existing issue by title, any state: a previously closed one is
|
||||||
|
# exactly the one to update and reopen, and searching all states is
|
||||||
|
# also what stops a duplicate being created after a close.
|
||||||
|
#
|
||||||
|
# The `|| true` matters: `set -e` plus `pipefail` would abort the step
|
||||||
|
# on a transient API error, and creating a second issue is a far worse
|
||||||
|
# outcome than missing one run.
|
||||||
|
NUMBER=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/repos/${REPO}/issues?state=all&type=issues&limit=100" \
|
||||||
|
| node -e '
|
||||||
|
let s = "";
|
||||||
|
process.stdin.on("data", d => s += d).on("end", () => {
|
||||||
|
try {
|
||||||
|
const issues = JSON.parse(s);
|
||||||
|
const hit = issues.find(i => i.title === "Dependency updates");
|
||||||
|
process.stdout.write(hit ? String(hit.number) : "");
|
||||||
|
} catch { process.stdout.write(""); }
|
||||||
|
});
|
||||||
|
' || true)
|
||||||
|
|
||||||
|
BODY=$(node -e 'process.stdout.write(JSON.stringify({ body: require("/tmp/state.json").body }))')
|
||||||
|
|
||||||
|
if [ -z "$NUMBER" ]; then
|
||||||
|
if [ "$ACTIONABLE" = "0" ]; then
|
||||||
|
echo "everything current and no issue open — nothing to do"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(node -e 'process.stdout.write(JSON.stringify({ title: "Dependency updates", body: require("/tmp/state.json").body }))')" \
|
||||||
|
"${API}/repos/${REPO}/issues" > /dev/null
|
||||||
|
echo "opened the tracking issue"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$BODY" "${API}/repos/${REPO}/issues/${NUMBER}" > /dev/null
|
||||||
|
echo "updated issue #${NUMBER}"
|
||||||
|
|
||||||
|
# Reopen when there is work, close when there isn't. Done as a second
|
||||||
|
# call because the state is not part of the body above.
|
||||||
|
if [ "$ACTIONABLE" = "0" ]; then WANT=closed; else WANT=open; fi
|
||||||
|
STATE=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/repos/${REPO}/issues/${NUMBER}" \
|
||||||
|
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).state))')
|
||||||
|
if [ "$STATE" != "$WANT" ]; then
|
||||||
|
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"state\":\"${WANT}\"}" "${API}/repos/${REPO}/issues/${NUMBER}" > /dev/null
|
||||||
|
echo "issue #${NUMBER}: ${STATE} -> ${WANT}"
|
||||||
|
fi
|
||||||
Reference in new issue
Block a user