Make the plugin contract version the release version, and publish the Docker Hub overview from CI
CI / full-gate (push) Successful in 3m8s
CI / full-gate (push) Successful in 3m8s
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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)/);
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user