Manage the published quick start's pins, and fail closed on every release-tooling edge
CI / full-gate (push) Successful in 2m50s
CI / full-gate (push) Successful in 2m50s
This commit is contained in:
@@ -2,8 +2,6 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||
# The overview has its own door: a stale page is exactly the state you cannot fix by cutting a
|
||||
# release, so republishing it must not require one.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
overview_version:
|
||||
@@ -22,6 +20,7 @@ jobs:
|
||||
env:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||
- name: Promote the commit-hash image to semver + latest
|
||||
@@ -31,6 +30,7 @@ jobs:
|
||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||
REPO: gitea.larvit.se/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
REPO: gitea.larvit.se/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||
@@ -60,14 +61,14 @@ jobs:
|
||||
- name: Log out of the registries
|
||||
if: always()
|
||||
run: |
|
||||
docker logout gitea.larvit.se
|
||||
docker logout docker.io
|
||||
set -uo pipefail
|
||||
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
|
||||
# fail a release that published, nor skip the overview job that follows.
|
||||
docker logout gitea.larvit.se || true
|
||||
docker logout docker.io || true
|
||||
|
||||
# Its own job, not a step: the images are already pushed and irreversible by this point, so a Hub
|
||||
# API outage or an under-scoped token leaves the promotion green and the images untouched (the run
|
||||
# still shows red — the failure is real, it just is not the release's).
|
||||
publish-overview:
|
||||
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
|
||||
if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success') }}
|
||||
needs: [retag-image]
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
@@ -80,10 +81,9 @@ jobs:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
INPUT_VERSION: ${{ inputs.overview_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||
# The manual door carries the same invariant as the tag door: this rejects a non-semver
|
||||
# VERSION (an empty input falls back to the branch name) and one whose major.minor
|
||||
# disagrees with the tree whose apiVersion sample is about to be published.
|
||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
|
||||
@@ -1416,12 +1416,14 @@ whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to se
|
||||
|
||||
**The Docker Hub overview** is published by a separate `publish-overview` job from
|
||||
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
|
||||
`{{VERSION}}` rendered to the release, so the image tags it tells adopters to pull cannot go stale.
|
||||
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
|
||||
stale. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
|
||||
quick start stays a topology CI has actually run.
|
||||
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
|
||||
outage leaves the promotion green and the images untouched; and the page has its own door — run the
|
||||
workflow manually with an `overview_version` input to republish it without cutting a release. That
|
||||
input goes through the same contract check as a tag, so a typo cannot publish a pull tag nobody can
|
||||
resolve. It uses
|
||||
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
|
||||
disagrees with the tree being published, is refused. It uses
|
||||
`DOCKERHUB_OVERVIEW_TOKEN`, separate from the image-push token because editing repository metadata is
|
||||
a different permission and widening the push credential to cover it would widen what a leak costs.
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// 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.
|
||||
// The gate that keeps the contract version and the release version one number (README → Contract
|
||||
// versioning): a tag whose major.minor disagrees with HOST_API_VERSION would ship a host that
|
||||
// misreports itself to every plugin's compatibility check.
|
||||
|
||||
export type ContractCheck = { ok: true } | { ok: false; error: string };
|
||||
|
||||
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
||||
const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
export function readHostApiVersion(source: string): string | null {
|
||||
return /HOST_API_VERSION\s*=\s*"([^"]+)"/.exec(source)?.[1] ?? null;
|
||||
return /^export const HOST_API_VERSION = "([^"]+)";/m.exec(source)?.[1] ?? null;
|
||||
}
|
||||
|
||||
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
|
||||
@@ -37,7 +36,13 @@ export function checkTagMatchesContract(tag: string, hostApiVersion: string | nu
|
||||
if (process.argv[1]?.endsWith("/contract-version.ts")) {
|
||||
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
|
||||
let source = "";
|
||||
try {
|
||||
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
|
||||
} catch (err) {
|
||||
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`${pluginPath}: ${result.error}\n`);
|
||||
|
||||
@@ -54,7 +54,7 @@ services:
|
||||
restart: "on-failure:5"
|
||||
|
||||
postgres:
|
||||
image: postgres:18.4-alpine3.23
|
||||
image: postgres:18.6-alpine3.23
|
||||
environment:
|
||||
POSTGRES_DB: ory
|
||||
POSTGRES_PASSWORD: ory
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
|
||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.1
|
||||
image: axllent/mailpit:v1.30.7
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
||||
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
||||
|
||||
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
|
||||
const template = () => readFileSync(TEMPLATE, "utf8");
|
||||
|
||||
test("renderOverview substitutes every occurrence, not just the first", () => {
|
||||
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
|
||||
@@ -13,10 +16,33 @@ test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes cle
|
||||
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
|
||||
});
|
||||
|
||||
test("the real template renders clean and pins no literal image tag", () => {
|
||||
const rendered = renderOverview(readFileSync("release-tooling/dockerhub-overview.md.tmpl", "utf8"), "9.9.9");
|
||||
test("the real template renders clean, and the release owns its own image tag", () => {
|
||||
const rendered = renderOverview(template(), "9.9.9");
|
||||
assert.deepEqual(leftoverPlaceholders(rendered), []);
|
||||
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
|
||||
// The release owns every image tag on the page, so a literal one must not survive rendering.
|
||||
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
|
||||
});
|
||||
|
||||
test("the quick start's sidecars are pinned to the same versions this repo runs", () => {
|
||||
// The page is published automatically, so a drifted pin here ships a topology CI never tested.
|
||||
const pins = (source: string) =>
|
||||
new Map([...source.matchAll(/image: ([^:\s]+):(v?\d\S*)/g)].map((m) => [m[1] ?? "", m[2] ?? ""]));
|
||||
const ours = new Map([
|
||||
...pins(readFileSync("compose.yml", "utf8")),
|
||||
...pins(readFileSync("compose.override.yml", "utf8")),
|
||||
]);
|
||||
const published = pins(template());
|
||||
assert.ok(published.size > 0, "the template should pin sidecars");
|
||||
for (const [image, tag] of published) {
|
||||
assert.equal(tag, ours.get(image), `${TEMPLATE} pins ${image}:${tag}, this repo runs ${ours.get(image)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("jwtFrom accepts only a non-empty string token, never throwing on a hostile body", () => {
|
||||
assert.equal(jwtFrom({ token: "abc" }), "abc");
|
||||
assert.equal(jwtFrom(null), null); // valid JSON, and the shape a proxy can return
|
||||
assert.equal(jwtFrom("<html>rate limited</html>"), null);
|
||||
assert.equal(jwtFrom({}), null);
|
||||
assert.equal(jwtFrom({ token: "" }), null);
|
||||
assert.equal(jwtFrom({ token: 42 }), null);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl. The page is the
|
||||
// first thing an adopter copies, so `{{VERSION}}` is rendered from the release being published
|
||||
// rather than written by hand.
|
||||
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
|
||||
// `{{VERSION}}` to the release being published.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const HUB = "https://hub.docker.com/v2";
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const VERSION = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
export function renderOverview(source: string, version: string): string {
|
||||
return source.replaceAll("{{VERSION}}", version);
|
||||
@@ -13,56 +17,77 @@ export function leftoverPlaceholders(rendered: string): string[] {
|
||||
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
|
||||
}
|
||||
|
||||
export function jwtFrom(body: unknown): string | null {
|
||||
if (typeof body !== "object" || body === null || !("token" in body)) return null;
|
||||
return typeof body.token === "string" && body.token !== "" ? body.token : null;
|
||||
}
|
||||
|
||||
type Fetched = { error: string } | { json: unknown; ok: boolean; status: number; text: string };
|
||||
|
||||
// fetch and its body readers throw; this is the one edge that converts that into a value.
|
||||
async function post(url: string, init: RequestInit): Promise<Fetched> {
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
const text = await res.text();
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { json, ok: res.ok, status: res.status, text };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const fail = (message: string): number => {
|
||||
process.stderr.write(`${message}\n`);
|
||||
return 1;
|
||||
};
|
||||
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_OVERVIEW_TOKEN"];
|
||||
if (!version || !repo || !user || !token) {
|
||||
process.stderr.write(
|
||||
"usage: dockerhub-overview.ts <version>; needs DOCKERHUB_REPO, DOCKERHUB_USER, DOCKERHUB_OVERVIEW_TOKEN\n",
|
||||
return fail(
|
||||
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
|
||||
"DOCKERHUB_OVERVIEW_TOKEN (README -> CI/CD)",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
// The page is public, so never render a version that resolves to no image.
|
||||
if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`);
|
||||
|
||||
const body = renderOverview(readFileSync("release-tooling/dockerhub-overview.md.tmpl", "utf8"), version);
|
||||
const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
|
||||
const body = renderOverview(readFileSync(templatePath, "utf8"), version);
|
||||
const leftover = leftoverPlaceholders(body);
|
||||
if (leftover.length > 0) {
|
||||
process.stderr.write(`release-tooling/dockerhub-overview.md.tmpl has unrendered placeholders: ${leftover.join(", ")}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`);
|
||||
|
||||
const login = await fetch(`${HUB}/users/login`, {
|
||||
const login = await post(`${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;
|
||||
}
|
||||
if ("error" in login) return fail(`Docker Hub login unreachable: ${login.error}`);
|
||||
if (!login.ok) return fail(`Docker Hub login failed: ${login.status} ${login.text}`);
|
||||
const jwt = jwtFrom(login.json);
|
||||
if (!jwt) return fail("Docker Hub login returned no token");
|
||||
|
||||
const res = await fetch(`${HUB}/repositories/${repo}/`, {
|
||||
const res = await post(`${HUB}/repositories/${repo}/`, {
|
||||
body: JSON.stringify({ full_description: body }),
|
||||
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
process.stderr.write(
|
||||
`Docker Hub overview PATCH failed: ${res.status} ${detail}\n` +
|
||||
return fail(
|
||||
`Docker Hub overview PATCH failed: ${res.status} ${res.text}` +
|
||||
(res.status === 403
|
||||
? "403 means DOCKERHUB_OVERVIEW_TOKEN cannot edit repository metadata — that is a separate " +
|
||||
"permission from pushing images, which is why it is its own secret (README -> CI/CD).\n"
|
||||
? "\n403 means DOCKERHUB_OVERVIEW_TOKEN cannot edit repository metadata — a separate " +
|
||||
"permission from pushing images, which is why it is its own secret (README -> CI/CD)."
|
||||
: ""),
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
|
||||
return 0;
|
||||
|
||||
@@ -51,6 +51,13 @@
|
||||
"depNameTemplate": "renovate/renovate",
|
||||
"datasourceTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "The published quick start ships a compose file, so its sidecars move with the repo's own pins. The version group starts at a digit, which skips the {{VERSION}} placeholder the release renders",
|
||||
"managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"],
|
||||
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
|
||||
"datasourceTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
|
||||
|
||||
Reference in New Issue
Block a user