Make the plugin contract version the release version, and publish the Docker Hub overview from CI
CI / full-gate (push) Successful in 3m8s

This commit is contained in:
2026-08-20 23:16:11 +02:00
parent 7f839afb32
commit 2148822dad
17 changed files with 270 additions and 50 deletions
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
});
test("the shipped tag and the shipped contract agree", () => {
// Guards the pair the release gate checks, so a bump to one fails here before it fails in CI.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0");
});
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
});
test("checkTagMatchesContract names what to fix rather than just failing", () => {
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
assert.equal(res.ok, false);
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
});
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
});
+45
View File
@@ -0,0 +1,45 @@
// The plugin contract version and the release version are one number (README → Contract
// versioning). This is the gate that keeps them one: a tag whose major.minor disagrees with
// HOST_API_VERSION would ship a host that misreports itself to every plugin's compatibility check.
// Pure and unit tested; the git/tag side lives in the workflows that call the CLI below.
export type ContractCheck = { ok: true } | { ok: false; error: string };
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/;
export function readHostApiVersion(source: string): string | null {
return /HOST_API_VERSION\s*=\s*"([^"]+)"/.exec(source)?.[1] ?? null;
}
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
// releases with no commit to bump the constant in.
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
if (hostApiVersion === null) {
return { error: "HOST_API_VERSION not found in src/plugin-host/plugin.ts", ok: false };
}
const t = SEMVER.exec(tag);
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
const h = SEMVER.exec(hostApiVersion);
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
return {
error:
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
"then tag.",
ok: false,
};
}
// CLI: node auto-release/contract-version.ts <tag> <path/to/plugin.ts> → exits 1 on mismatch.
if (process.argv[1]?.endsWith("/contract-version.ts")) {
const [, , tag, pluginPath] = process.argv;
const { readFileSync } = await import("node:fs");
const source = readFileSync(pluginPath ?? "src/plugin-host/plugin.ts", "utf8");
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
if (!result.ok) {
process.stderr.write(`${result.error}\n`);
process.exit(1);
}
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
test("renderOverview substitutes every occurrence, not just the first", () => {
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
});
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
});
test("the real README-dockerhub.md renders clean and pins no literal image tag", () => {
const rendered = renderOverview(readFileSync("README-dockerhub.md", "utf8"), "9.9.9");
assert.deepEqual(leftoverPlaceholders(rendered), []);
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
// A hardcoded version here is what went stale on the live page; the release must own every one.
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
});
+71
View File
@@ -0,0 +1,71 @@
// Publishes README-dockerhub.md as the Docker Hub repository overview. The page is the first thing
// an adopter copies, and its image tags were maintained by hand — they pointed at a version that no
// longer existed. `{{VERSION}}` is rendered from the tag being promoted, so they cannot go stale.
const HUB = "https://hub.docker.com/v2";
export function renderOverview(source: string, version: string): string {
return source.replaceAll("{{VERSION}}", version);
}
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
export function leftoverPlaceholders(rendered: string): string[] {
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
}
async function main(): Promise<number> {
const [, , version] = process.argv;
const { readFileSync } = await import("node:fs");
const repo = process.env["DOCKERHUB_REPO"];
const user = process.env["DOCKERHUB_USER"];
const token = process.env["DOCKERHUB_TOKEN"];
if (!version || !repo || !user || !token) {
process.stderr.write("usage: dockerhub-overview.ts <version>; needs DOCKERHUB_REPO/USER/TOKEN\n");
return 1;
}
const body = renderOverview(readFileSync("README-dockerhub.md", "utf8"), version);
const leftover = leftoverPlaceholders(body);
if (leftover.length > 0) {
process.stderr.write(`README-dockerhub.md has unrendered placeholders: ${leftover.join(", ")}\n`);
return 1;
}
const login = await fetch(`${HUB}/users/login`, {
body: JSON.stringify({ password: token, username: user }),
headers: { "content-type": "application/json" },
method: "POST",
});
if (!login.ok) {
process.stderr.write(`Docker Hub login failed: ${login.status} ${await login.text()}\n`);
return 1;
}
const { token: jwt } = (await login.json()) as { token?: string };
if (!jwt) {
process.stderr.write("Docker Hub login returned no token\n");
return 1;
}
const res = await fetch(`${HUB}/repositories/${repo}/`, {
body: JSON.stringify({ full_description: body }),
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
method: "PATCH",
});
if (!res.ok) {
const detail = await res.text();
process.stderr.write(
`Docker Hub overview PATCH failed: ${res.status} ${detail}\n` +
(res.status === 403
? "403 usually means the token is scoped to the repository's images only — publishing the " +
"overview edits repository metadata and needs a token with that permission (README -> CI/CD).\n"
: ""),
);
return 1;
}
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
return 0;
}
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
process.exit(await main());
}