diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e57c8dc..03cd8c6 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 \ diff --git a/README.md b/README.md index 430f981..414fecd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/release-tooling/contract-version.ts b/release-tooling/contract-version.ts index 96ab281..5248ce7 100644 --- a/release-tooling/contract-version.ts +++ b/release-tooling/contract-version.ts @@ -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`); diff --git a/release-tooling/dockerhub-overview.md.tmpl b/release-tooling/dockerhub-overview.md.tmpl index 6921772..8813f06 100644 --- a/release-tooling/dockerhub-overview.md.tmpl +++ b/release-tooling/dockerhub-overview.md.tmpl @@ -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 diff --git a/release-tooling/dockerhub-overview.test.ts b/release-tooling/dockerhub-overview.test.ts index 56571cf..d383a1e 100644 --- a/release-tooling/dockerhub-overview.test.ts +++ b/release-tooling/dockerhub-overview.test.ts @@ -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+(? { + // 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("rate limited"), null); + assert.equal(jwtFrom({}), null); + assert.equal(jwtFrom({ token: "" }), null); + assert.equal(jwtFrom({ token: 42 }), null); +}); diff --git a/release-tooling/dockerhub-overview.ts b/release-tooling/dockerhub-overview.ts index 48d8275..6d0b86f 100644 --- a/release-tooling/dockerhub-overview.ts +++ b/release-tooling/dockerhub-overview.ts @@ -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 { + 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 { + 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 ; needs DOCKERHUB_REPO, DOCKERHUB_USER, DOCKERHUB_OVERVIEW_TOKEN\n", + return fail( + "usage: dockerhub-overview.ts ; 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; diff --git a/renovate.json b/renovate.json index e29a25f..1235384 100644 --- a/renovate.json +++ b/renovate.json @@ -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: (?[^:\\s]+):(?v?\\d[^\\s]*)"], + "datasourceTemplate": "docker" + }, { "customType": "regex", "description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",