From 7f839afb32cc82c21ba7df94bb1b5514a7fa9185 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 20 Aug 2026 23:02:21 +0200 Subject: [PATCH 01/15] Release nothing when no Renovate commit reached the app --- .gitea/workflows/renovate.yml | 6 +++++- README.md | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index 10428c7..af25981 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -26,7 +26,8 @@ jobs: # After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the # trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit - # (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's + # (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:` + # trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's # authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer; # pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the # already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't). @@ -54,6 +55,9 @@ jobs: fi BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \ --format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; }) + if [ -z "$BUMPS" ]; then + echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 + fi NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ node auto-release/next-version.ts "$LATEST" $BUMPS) echo "Releasing $LATEST -> $NEXT" diff --git a/README.md b/README.md index 51f9d64..31bafa7 100644 --- a/README.md +++ b/README.md @@ -1419,7 +1419,8 @@ exact. Each PR runs the normal gate on its `renovate/*` branch and automerges on **Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and -**skips** when the tip isn't a Renovate commit or nothing new merged. Renovate stamps a +**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a +trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a `Release-Bump: ` trailer onto the updates that reach a running Plainpages — the root `package.json`'s runtime dependencies, the image base, and `compose.yml`'s services — and [`auto-release/next-version.ts`](auto-release/next-version.ts) turns the highest one into the next -- 2.52.0 From 2148822dad65d5cf1b0bcaecc582873c1c56d416 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 20 Aug 2026 23:16:11 +0200 Subject: [PATCH 02/15] Make the plugin contract version the release version, and publish the Docker Hub overview from CI --- .gitea/workflows/release.yml | 19 ++++++ .gitea/workflows/renovate.yml | 5 ++ AGENTS.md | 10 ++- README-dockerhub.md | 8 +-- README.md | 44 +++++++++----- examples/plugins/admin/plugin.ts | 2 +- examples/plugins/scheduling/plugin.ts | 2 +- package.json | 2 +- release-tooling/contract-version.test.ts | 35 +++++++++++ release-tooling/contract-version.ts | 45 ++++++++++++++ release-tooling/dockerhub-overview.test.ts | 22 +++++++ release-tooling/dockerhub-overview.ts | 71 ++++++++++++++++++++++ src/plugin-host/discovery.test.ts | 41 +++++++------ src/plugin-host/plugin.test.ts | 4 +- src/plugin-host/plugin.ts | 6 +- tsconfig.json | 2 +- views/index.ejs | 2 +- 17 files changed, 270 insertions(+), 50 deletions(-) create mode 100644 release-tooling/contract-version.test.ts create mode 100644 release-tooling/contract-version.ts create mode 100644 release-tooling/dockerhub-overview.test.ts create mode 100644 release-tooling/dockerhub-overview.ts diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 05bbfd8..39ed22b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -8,6 +8,14 @@ jobs: runs-on: docker-host steps: - uses: actions/checkout@v7.0.1 + # Before anything is published: the contract version IS the release version, so a tag that + # disagrees would ship a host misreporting itself to every plugin's compatibility check. + - name: Refuse a tag that disagrees with HOST_API_VERSION + env: + GIT_TAG: ${{ github.ref_name }} + run: | + 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 env: GIT_TAG: ${{ github.ref_name }} @@ -41,6 +49,17 @@ jobs: docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG" docker push "$DOCKERHUB_REPO:$TAG" done + - name: Publish the Docker Hub overview + env: + DOCKERHUB_REPO: ${{ github.repository }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} + GIT_TAG: ${{ github.ref_name }} + run: | + docker run --rm -v "$PWD:/repo" -w /repo \ + -e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \ + node:24.19.0-alpine3.24 \ + node release-tooling/dockerhub-overview.ts "${GIT_TAG#v}" - name: Log out of the registries if: always() run: | diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index af25981..c593264 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -60,6 +60,11 @@ jobs: fi NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ node auto-release/next-version.ts "$LATEST" $BUMPS) + # Read the constant off origin/main, not the checkout, which lags the merges this run made. + git show origin/main:src/plugin-host/plugin.ts > plugin-at-main.ts + docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ + node release-tooling/contract-version.ts "$NEXT" plugin-at-main.ts + rm -f plugin-at-main.ts echo "Releasing $LATEST -> $NEXT" git tag "$NEXT" origin/main git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT" diff --git a/AGENTS.md b/AGENTS.md index 0410c47..6abbaf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -352,9 +352,13 @@ one-time setup. A file-map or table row gets a clause, not a paragraph. trailer, so a dependency added anywhere else never escalates the release version and nothing fails to say so. A new manifest, compose file, custom manager or dep type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing and let it ride the next patch. -- **`HOST_API_VERSION` is a live promise as of the v0.1.0 release** (the app's version and the - contract's move independently). Bump it with every contract change, per the table in README → - Contract versioning: major on a breaking one, minor on an additive one. **The contract surface +- **`HOST_API_VERSION` *is* the release version — one number, not two.** Its `major.minor` must equal + the release tag's, and both release paths refuse a tag that disagrees + (`release-tooling/contract-version.ts`). The patch digit may lag on purpose: `checkApiVersion` + ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a + dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release` + stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge + it, then tag. Never bump it to "catch up" with a patch release. **The contract surface includes `views/partials/*.ejs`** — the view resolver makes every core partial an `include()` root for a plugin's views, so their option names and emitted markup are author-visible. Know the hole that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })` diff --git a/README-dockerhub.md b/README-dockerhub.md index cff564d..6921772 100644 --- a/README-dockerhub.md +++ b/README-dockerhub.md @@ -22,7 +22,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`: ```yaml services: web: - image: larvit/plainpages:0.1.0 + image: larvit/plainpages:{{VERSION}} ports: - "3000:3000" environment: @@ -41,7 +41,7 @@ services: # One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user. bootstrap: - image: larvit/plainpages:0.1.0 + image: larvit/plainpages:{{VERSION}} command: node src/auth/bootstrap.ts depends_on: kratos: @@ -143,7 +143,7 @@ volumes: Extract the Ory config the image ships, then start: ```bash -docker run --rm larvit/plainpages:0.1.0 tar -cf - ory | tar -xf - +docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf - mkdir -p plugins docker compose up -d ``` @@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`: import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ - apiVersion: "1.0.0", + apiVersion: "0.1.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, diff --git a/README.md b/README.md index 31bafa7..d61db80 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin. import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ - apiVersion: "1.0.0", + apiVersion: "0.1.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, @@ -348,7 +348,7 @@ import { definePlugin } from "@plainpages/plugin-api"; import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ - apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning) + apiVersion: "0.1.0", // semver string of the host contract this plugin was built against (see Versioning) // Nav fragment, merged into the global menu and permission-filtered per user. // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). @@ -468,7 +468,7 @@ import { definePlugin } from "@plainpages/plugin-api"; import { landing, board } from "./pages.ts"; export default definePlugin({ - apiVersion: "1.0.0", + apiVersion: "0.1.0", home: landing, // owns "/" — the public front page dashboard: board, // owns "/dashboard" — the post-login app home }); @@ -593,11 +593,15 @@ works without editing host config. ### Contract versioning -Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built -against — against the host's `HOST_API_VERSION`. The host bumps **major** on a breaking -manifest/handler change and **minor** on an additive one. At discovery it parses both with -`parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies provider/consumer -semantics in `checkApiVersion`: +Each manifest declares `apiVersion` — a **semver** string naming the **Plainpages release** it was +built against. There is no second number to track: the host's `HOST_API_VERSION` *is* its release +version, so a plugin author reads one version off the image they run and writes it down. Both release +paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift. + +Patch releases are invisible here — `checkApiVersion` ignores the patch digit, which is what lets +dependency updates ship continuously without touching any plugin. At discovery the host parses both +versions with `parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies +provider/consumer semantics in `checkApiVersion`: | Plugin `apiVersion` vs host | Result | Host action | | --- | --- | --- | @@ -608,7 +612,9 @@ semantics in `checkApiVersion`: | missing / not a valid semver | `refuse` | **abort boot** — must be declared | The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies -the caret-style compatibility. +the caret-style compatibility. While Plainpages is `0.x` every release shares major `0`, so a plugin +built against `0.1.0` still loads on a `0.9.0` host with a `warn`; reaching `1.0.0` refuses everything +built against `0.x`, which is the point of that milestone. ### Conflict rules @@ -742,7 +748,7 @@ import { definePlugin } from "@plainpages/plugin-api"; let sql: ReturnType; export default definePlugin({ - apiVersion: "1.0.0", + apiVersion: "0.1.0", storage: true, hooks: { onBoot: async (boot) => { @@ -1372,7 +1378,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs | Workflow | Trigger | Does | | --- | --- | --- | | `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image | -| `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub | +| `release.yml` | push of a `vX.Y.Z` tag | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`, sync those tags to Docker Hub and publish its overview | | `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | | `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head | | `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged | @@ -1400,8 +1406,15 @@ release tags and would delete images the workflow protects. `release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `1`, `latest`; nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no hash image exists — release tags must point at a commit that went through the gate. The same four -tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. The Docker Hub -repository **description** is maintained by hand from [`README-dockerhub.md`](README-dockerhub.md). +tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. + +Two things guard the tag before anything is published. The +[contract check](#contract-versioning) refuses a tag whose `major.minor` disagrees with +`HOST_API_VERSION`, naming the value to set. And the Docker Hub repository **overview** is published +from [`README-dockerhub.md`](README-dockerhub.md) by the same job, with `{{VERSION}}` rendered to the +release — so the image tags it tells adopters to pull cannot go stale. That needs `DOCKERHUB_TOKEN` +to carry permission to edit repository metadata, not just push images; the step names this if it +403s. **GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift. @@ -1638,9 +1651,12 @@ examples/ Copy-in reference mirroring the mount dirs: plugins/schedul config/menu.ts, and shifts-upstream/ (the dev mock backend) e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml; proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow +auto-release/ Release-version math for the Renovate auto-release (next-version.ts) +release-tooling/ Run by the release itself: the HOST_API_VERSION↔tag gate, the Docker Hub publisher +registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts) ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack .gitea/workflows/ Gitea Actions — see CI/CD -README-dockerhub.md The Docker Hub repository description, pasted over by hand when it changes +README-dockerhub.md The Docker Hub repository overview; release.yml renders {{VERSION}} and publishes it ``` ## Extending the core diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index ac1f009..ece09d0 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -26,7 +26,7 @@ const groups = on("groups"); const clients = on("oauth2-clients"); export default definePlugin({ - apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION + apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION nav: [ADMIN_NAV], diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index 61753d4..9b3cb1a 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -11,7 +11,7 @@ const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstrea const upstream = createUpstream(upstreamUrl); export default definePlugin({ - apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION + apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION // onBoot runs after discovery, before the server listens: validate the plugin's own config so a // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. diff --git a/package.json b/package.json index 322423e..1a1be55 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev": "node --watch src/server.ts", "gen-jwks": "node src/auth/gen-jwks.ts", "typecheck": "tsc --noEmit", - "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\"" + "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\" \"auto-release/**/*.test.ts\"" }, "dependencies": { "@larvit/log": "2.3.0", diff --git a/release-tooling/contract-version.test.ts b/release-tooling/contract-version.test.ts new file mode 100644 index 0000000..954ead3 --- /dev/null +++ b/release-tooling/contract-version.test.ts @@ -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); +}); diff --git a/release-tooling/contract-version.ts b/release-tooling/contract-version.ts new file mode 100644 index 0000000..103dd06 --- /dev/null +++ b/release-tooling/contract-version.ts @@ -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 → 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`); +} diff --git a/release-tooling/dockerhub-overview.test.ts b/release-tooling/dockerhub-overview.test.ts new file mode 100644 index 0000000..95e8564 --- /dev/null +++ b/release-tooling/dockerhub-overview.test.ts @@ -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+(? { + 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 ; 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()); +} diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index 91966db..14ef216 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test, type TestContext } from "node:test"; import { discoverPlugins } from "./discovery.ts"; +import { HOST_API_VERSION } from "./plugin.ts"; // Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures // default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest. @@ -19,7 +20,7 @@ function scaffold(t: TestContext, files: Record): string { } const full = (id: string): string => - `export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` + + `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` + `routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`; test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => { @@ -30,12 +31,12 @@ test("discovers each folder's manifest, sorted, id derived from the folder name" const dir = scaffold(t, { "beta/plugin.ts": full("beta"), "alpha/plugin.ts": full("alpha"), - "gamma/plugin.ts": `export default { apiVersion: "1.0.0", storage: true };`, + "gamma/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: true };`, }); const plugins = await discoverPlugins({ dir }); assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order - assert.equal(plugins[0]?.apiVersion, "1.0.0"); + assert.equal(plugins[0]?.apiVersion, HOST_API_VERSION); assert.equal(plugins[0]?.nav?.[0]?.label, "alpha"); assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import assert.equal(plugins[0]?.storage, undefined); // storage is opt-in, never assumed @@ -51,21 +52,21 @@ const badCases: Array<{ name: string; files: Record; match: RegE { name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s }, { name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s }, { name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s }, - { name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s }, - { name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s }, - { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, - { name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "1.0.0", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s }, + { name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: "nope" };` }, match: /weird.*routes.*array/s }, + { name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: "nope" };` }, match: /weirdhome.*home.*function/s }, + { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, + { name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s }, // The folder name becomes a Postgres identifier, which truncates past 63 bytes. - { name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "1.0.0", storage: true };` }, match: /storage.*56 characters/s }, + { name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, - { name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, - { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s }, + { name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, + { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s }, // A permission name is : wherever the manifest mentions one. Enforced here, not // only in the admin GUI, so it holds for a plugin installed without that GUI. - { name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*:/s }, - { name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*:/s }, - { name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*:/s }, + { name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*:/s }, + { name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*:/s }, + { name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*:/s }, { name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s }, { name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s }, { name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s }, @@ -73,8 +74,8 @@ const badCases: Array<{ name: string; files: Record; match: RegE // `npm install --prefix plugins` — the documented command with one path segment dropped. { name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ }, { name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ }, - { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, - { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, + { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "b" }) };` }, match: /home/ }, + { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, ]; for (const c of badCases) { @@ -87,7 +88,7 @@ for (const c of badCases) { // upgrade, not the author of the manifest — so the message has to carry the remedy, not just the // rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case. test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => { - const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` }); + const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` }); await assert.rejects(discoverPlugins({ dir }), (err: Error) => { assert.match(err.message, /gates on "admin"/); // what is wrong assert.match(err.message, /re-copy it/); // …and what to do about it @@ -96,7 +97,7 @@ test("a discovery failure tells the operator their plugins/ copy may just be out }); test("a route + nav node may be marked public and load fine", async (t) => { - const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` }); + const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` }); const plugins = await discoverPlugins({ dir }); assert.equal(plugins.length, 1); assert.equal(plugins[0]?.routes?.[0]?.public, true); @@ -111,7 +112,7 @@ test("`admin` is not reserved — the admin screens ship as a drop-in plugin mou }); test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => { - const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` }); + const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` }); const plugins = await discoverPlugins({ dir }); assert.equal(plugins.length, 1); assert.equal(typeof plugins[0]?.home, "function"); @@ -126,7 +127,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a "shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`, "shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`, "shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` + - `export default definePlugin({ apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`, + `export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`, }); const plugins = await discoverPlugins({ dir }); @@ -153,7 +154,7 @@ test("a dangling plugin symlink fails loud rather than vanishing", async (t) => }); test("a shared permission name only warns — both plugins still load", async (t) => { - const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`; + const shared = `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "shared:read" }] };`; const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); const warnings: string[] = []; const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } }); diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index e56ff8f..6fde120 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -20,7 +20,7 @@ import { AUTH_FLOWS } from "../auth/flow-view.ts"; // HOST_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the // host derives both from the plugin's folder name. const scheduling: PluginManifest = definePlugin({ - apiVersion: "1.0.0", + apiVersion: "0.1.0", hooks: { onBoot: () => {} }, nav: [{ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], @@ -80,7 +80,7 @@ test("parseSemver follows the semver core, rejecting ranges, prefixes, leading z }); test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newer-minor/major-mismatch/malformed refuse", () => { - assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // "1.0.0" vs "1.0.0" + assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // the host always accepts its own version assert.equal(checkApiVersion("1.0.5", "1.0.0").level, "ok"); // patch never affects compatibility assert.equal(checkApiVersion("1.0.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 18151af..00dbe97 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -8,8 +8,10 @@ import type { RequestContext } from "../http/context.ts"; import type { NavNode } from "../ui/nav.ts"; import type { StorageCredentials } from "./storage.ts"; -// Bump major on a breaking manifest/handler change, minor on an additive one. -export const HOST_API_VERSION = "1.0.0"; +// The Plainpages release this contract ships in — one version, not a second one to track. Its +// major.minor must equal the release tag's; `release.yml` refuses to promote a tag that disagrees. +// The patch digit may lag, since checkApiVersion ignores patch and auto-release cuts patches itself. +export const HOST_API_VERSION = "0.1.0"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; diff --git a/tsconfig.json b/tsconfig.json index 3eec1c1..b903c3e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "include": ["auto-release", "config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "src"] + "include": ["auto-release", "config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "release-tooling", "src"] } diff --git a/views/index.ejs b/views/index.ejs index 7d17fb4..118c8e2 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -14,7 +14,7 @@

${t("dashboard.starter.intro")}

${t("dashboard.starter.replace")}

export default definePlugin({
-  apiVersion: "1.0.0",
+  apiVersion: "0.1.0",
   // view names plugins/<id>/views/<view>.ejs, rendered in this same shell
   dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
 });
-- 2.52.0 From 99e77ebabf36a758bdbb179ade247ec6690b3c26 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 20 Aug 2026 23:32:10 +0200 Subject: [PATCH 03/15] Release this as v0.2.0, and give the Hub overview its own job, token and template --- .gitea/workflows/release.yml | 47 +++++++++----- .gitea/workflows/renovate.yml | 11 ++-- AGENTS.md | 5 +- README.md | 61 +++++++++++++------ examples/plugins/admin/plugin.ts | 2 +- examples/plugins/scheduling/plugin.ts | 2 +- package.json | 2 +- release-tooling/contract-version.test.ts | 50 +++++++++++---- release-tooling/contract-version.ts | 12 ++-- .../dockerhub-overview.md.tmpl | 2 +- release-tooling/dockerhub-overview.test.ts | 4 +- release-tooling/dockerhub-overview.ts | 20 +++--- .../next-version.test.ts | 0 .../next-version.ts | 2 +- renovate.json | 4 +- src/plugin-host/plugin.test.ts | 2 +- src/plugin-host/plugin.ts | 4 +- src/server.ts | 3 +- tsconfig.json | 2 +- views/index.ejs | 2 +- 20 files changed, 155 insertions(+), 82 deletions(-) rename README-dockerhub.md => release-tooling/dockerhub-overview.md.tmpl (99%) rename {auto-release => release-tooling}/next-version.test.ts (100%) rename {auto-release => release-tooling}/next-version.ts (95%) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 39ed22b..9697a85 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,9 +2,17 @@ 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: + description: 'Version the overview should tell adopters to pull (e.g. 0.2.0)' + required: true jobs: retag-image: + if: github.event_name == 'push' runs-on: docker-host steps: - uses: actions/checkout@v7.0.1 @@ -34,7 +42,7 @@ jobs: done - name: Sync the release tags to Docker Hub env: - DOCKERHUB_REPO: docker.io/${{ github.repository }} + DOCKERHUB_IMAGE: docker.io/${{ github.repository }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} GIT_TAG: ${{ github.ref_name }} @@ -46,22 +54,33 @@ jobs: || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; } printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do - docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG" - docker push "$DOCKERHUB_REPO:$TAG" + docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG" + docker push "$DOCKERHUB_IMAGE:$TAG" done - - name: Publish the Docker Hub overview - env: - DOCKERHUB_REPO: ${{ github.repository }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} - GIT_TAG: ${{ github.ref_name }} - run: | - docker run --rm -v "$PWD:/repo" -w /repo \ - -e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \ - node:24.19.0-alpine3.24 \ - node release-tooling/dockerhub-overview.ts "${GIT_TAG#v}" - name: Log out of the registries if: always() run: | docker logout gitea.larvit.se docker logout docker.io + + # 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 must not report the release itself as failed. + publish-overview: + if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success') + needs: [retag-image] + runs-on: docker-host + steps: + - uses: actions/checkout@v7.0.1 + - name: Publish the Docker Hub overview + env: + DOCKERHUB_OVERVIEW_TOKEN: ${{ secrets.DOCKERHUB_OVERVIEW_TOKEN }} + DOCKERHUB_REPO: ${{ github.repository }} + DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} + GIT_TAG: ${{ github.ref_name }} + INPUT_VERSION: ${{ inputs.overview_version }} + run: | + VERSION=${INPUT_VERSION:-${GIT_TAG#v}} + docker run --rm -v "$PWD:/repo" -w /repo \ + -e DOCKERHUB_OVERVIEW_TOKEN -e DOCKERHUB_REPO -e DOCKERHUB_USER \ + node:24.19.0-alpine3.24 \ + node release-tooling/dockerhub-overview.ts "$VERSION" diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index c593264..d5124e4 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -29,7 +29,7 @@ jobs: # (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:` # trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's # authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer; - # pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the + # pre-1.0 shifts down (release-tooling/next-version.ts). Tag-only — release.yml promotes the # already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't). auto-release: runs-on: docker-host @@ -59,12 +59,11 @@ jobs: echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 fi NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ - node auto-release/next-version.ts "$LATEST" $BUMPS) + node release-tooling/next-version.ts "$LATEST" $BUMPS) # Read the constant off origin/main, not the checkout, which lags the merges this run made. - git show origin/main:src/plugin-host/plugin.ts > plugin-at-main.ts - docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ - node release-tooling/contract-version.ts "$NEXT" plugin-at-main.ts - rm -f plugin-at-main.ts + git show origin/main:src/plugin-host/plugin.ts \ + | docker run -i --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ + node release-tooling/contract-version.ts "$NEXT" - echo "Releasing $LATEST -> $NEXT" git tag "$NEXT" origin/main git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT" diff --git a/AGENTS.md b/AGENTS.md index 6abbaf8..de88490 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,8 +291,9 @@ Revisit only if the stated reason stops holding. workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so `git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a - *text* guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes - load-bearing. + *text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is + `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test + guards it, so renaming it back would let a broken `{{VERSION}}` merge with its own guard skipped. - **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that diff --git a/README.md b/README.md index d61db80..91866b7 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin. import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, @@ -348,7 +348,7 @@ import { definePlugin } from "@plainpages/plugin-api"; import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ - apiVersion: "0.1.0", // semver string of the host contract this plugin was built against (see Versioning) + apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning) // Nav fragment, merged into the global menu and permission-filtered per user. // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). @@ -468,7 +468,7 @@ import { definePlugin } from "@plainpages/plugin-api"; import { landing, board } from "./pages.ts"; export default definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", home: landing, // owns "/" — the public front page dashboard: board, // owns "/dashboard" — the post-login app home }); @@ -612,7 +612,10 @@ provider/consumer semantics in `checkApiVersion`: | missing / not a valid semver | `refuse` | **abort boot** — must be declared | The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies -the caret-style compatibility. While Plainpages is `0.x` every release shares major `0`, so a plugin +the caret-style compatibility. Note what a **minor** means now that one digit carries the whole +release: either the plugin contract changed, or a dependency moved far enough to warrant one. A +`warn` therefore says "built against an older release", not "new plugin features exist" — check the +Upgrading section for that release before assuming there is anything to adopt. While Plainpages is `0.x` every release shares major `0`, so a plugin built against `0.1.0` still loads on a `0.9.0` host with a `warn`; reaching `1.0.0` refuses everything built against `0.x`, which is the point of that milestone. @@ -748,7 +751,7 @@ import { definePlugin } from "@plainpages/plugin-api"; let sql: ReturnType; export default definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", storage: true, hooks: { onBoot: async (boot) => { @@ -1378,7 +1381,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs | Workflow | Trigger | Does | | --- | --- | --- | | `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image | -| `release.yml` | push of a `vX.Y.Z` tag | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`, sync those tags to Docker Hub and publish its overview | +| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`, sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger | | `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | | `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head | | `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged | @@ -1408,13 +1411,17 @@ nothing is rebuilt, so the released image is byte-identical to the gated one. It hash image exists — release tags must point at a commit that went through the gate. The same four tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. -Two things guard the tag before anything is published. The -[contract check](#contract-versioning) refuses a tag whose `major.minor` disagrees with -`HOST_API_VERSION`, naming the value to set. And the Docker Hub repository **overview** is published -from [`README-dockerhub.md`](README-dockerhub.md) by the same job, with `{{VERSION}}` rendered to the -release — so the image tags it tells adopters to pull cannot go stale. That needs `DOCKERHUB_TOKEN` -to carry permission to edit repository metadata, not just push images; the step names this if it -403s. +The [contract check](#contract-versioning) guards the tag before anything is published, refusing one +whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set. + +**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. +It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub +outage must not report a good release as failed; and the page has its own door — run the workflow +manually with an `overview_version` input to republish it without cutting a release. 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. **GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift. @@ -1436,8 +1443,11 @@ exact. Each PR runs the normal gate on its `renovate/*` branch and automerges on trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a `Release-Bump: ` trailer onto the updates that reach a running Plainpages — the root `package.json`'s runtime dependencies, the image base, and `compose.yml`'s services — and -[`auto-release/next-version.ts`](auto-release/next-version.ts) turns the highest one into the next -version; pre-1.0 it never auto-crosses into `1.0.0`. `updateType` rates the *dependency's* own jump, +[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next +version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release +version, an update big enough to reach a **minor** stops the job rather than tagging: bump +`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since +`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump, so the trailer is an allowlist in [`renovate.json`](renovate.json): a devDependency, E2E or CI-only bump carries none and rides the next patch release instead of escalating it. It is **tag-only**: the tag hands off to `release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by @@ -1449,6 +1459,7 @@ the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never tou | --- | --- | | `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. | | `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a read/write token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT can push to every repo under it). | +| `DOCKERHUB_OVERVIEW_TOKEN` (secret) | A Docker Hub PAT that may **edit repository metadata**, used only to publish the overview. Separate from `DOCKERHUB_TOKEN` so the image-push credential stays narrow; without it the `publish-overview` job fails and the released images are unaffected. | | `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. | | `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. | | `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. | @@ -1502,6 +1513,21 @@ box. The web app waits for Kratos + Keto healthy *and* the bootstrap to finish b ## Upgrading +### 0.1.x → 0.2.0: `apiVersion` now names the Plainpages release + +`0.1.0` shipped a host reporting contract version `1.0.0` — a second number that tracked the plugin +contract separately from the release. There is only one number now, and it is the release: a host on +`0.2.0` reports `0.2.0`. Every plugin must say so, or discovery refuses it by version and **aborts +boot**: + +```ts +// plugins//plugin.ts +apiVersion: "0.2.0", // was "1.0.0" +``` + +Re-copy anything taken from `examples/` (below) to pick this up. Nothing else in the contract +changed, so a plugin that boots after the edit needs no further work. + **Re-copy your drop-in plugins.** Anything under `plugins/` is *your* copy — the host never updates it. A plugin copied from `examples/` is still the old one after you pull, and the host may have tightened a manifest rule since. Discovery fails loud at boot rather than running a plugin it can't @@ -1651,12 +1677,11 @@ examples/ Copy-in reference mirroring the mount dirs: plugins/schedul config/menu.ts, and shifts-upstream/ (the dev mock backend) e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml; proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow -auto-release/ Release-version math for the Renovate auto-release (next-version.ts) -release-tooling/ Run by the release itself: the HOST_API_VERSION↔tag gate, the Docker Hub publisher +release-tooling/ Everything the release runs: next-version (the bump math), contract-version + (the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl) registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts) ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack .gitea/workflows/ Gitea Actions — see CI/CD -README-dockerhub.md The Docker Hub repository overview; release.yml renders {{VERSION}} and publishes it ``` ## Extending the core diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index ece09d0..ec19a8b 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -26,7 +26,7 @@ const groups = on("groups"); const clients = on("oauth2-clients"); export default definePlugin({ - apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION + apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION nav: [ADMIN_NAV], diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index 9b3cb1a..2bf86eb 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -11,7 +11,7 @@ const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstrea const upstream = createUpstream(upstreamUrl); export default definePlugin({ - apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION + apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION // onBoot runs after discovery, before the server listens: validate the plugin's own config so a // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. diff --git a/package.json b/package.json index 1a1be55..e0cde0d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev": "node --watch src/server.ts", "gen-jwks": "node src/auth/gen-jwks.ts", "typecheck": "tsc --noEmit", - "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\" \"auto-release/**/*.test.ts\"" + "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\"" }, "dependencies": { "@larvit/log": "2.3.0", diff --git a/release-tooling/contract-version.test.ts b/release-tooling/contract-version.test.ts index 954ead3..c48a994 100644 --- a/release-tooling/contract-version.test.ts +++ b/release-tooling/contract-version.test.ts @@ -9,27 +9,51 @@ test("readHostApiVersion pulls the constant out of the real source, and returns 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("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => { + // Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that + // makes an accidental edit fail here rather than at release time. + assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0"); +}); + +test("every author-facing apiVersion sample matches the shipped contract", () => { + // A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The + // examples deliberately write a literal rather than importing the constant (AGENTS.md), so this + // is the only thing keeping the copies honest. + const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? ""; + const [major, minor] = host.split("."); + for (const file of [ + "README.md", + "examples/plugins/admin/plugin.ts", + "examples/plugins/scheduling/plugin.ts", + "release-tooling/dockerhub-overview.md.tmpl", + "views/index.ejs", + ]) { + const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]); + assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`); + for (const sample of found) { + const [sMajor, sMinor] = (sample ?? "").split("."); + assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`); + } + } }); 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); + assert.equal(checkTagMatchesContract("v0.2.0", "0.2.0").ok, true); + assert.equal(checkTagMatchesContract("v0.2.7", "0.2.0").ok, true); // auto-release cut patches + assert.equal(checkTagMatchesContract("0.2.0", "0.2.0").ok, true); // bare tag, no v + assert.equal(checkTagMatchesContract("v0.3.0", "0.2.0").ok, false); // plugin-visible, needs a bump + assert.equal(checkTagMatchesContract("v0.1.0", "0.2.0").ok, false); // the previously released line + assert.equal(checkTagMatchesContract("v1.0.0", "0.2.0").ok, false); }); test("checkTagMatchesContract names what to fix rather than just failing", () => { - const res = checkTagMatchesContract("v0.2.0", "0.1.0"); + const res = checkTagMatchesContract("v0.3.0", "0.2.0"); assert.equal(res.ok, false); - assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/); + assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.3\.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); + assert.equal(checkTagMatchesContract("v0.2.0", null).ok, false); // constant not found + assert.equal(checkTagMatchesContract("nope", "0.2.0").ok, false); + assert.equal(checkTagMatchesContract("v0.2.0", "1.0").ok, false); }); diff --git a/release-tooling/contract-version.ts b/release-tooling/contract-version.ts index 103dd06..96ab281 100644 --- a/release-tooling/contract-version.ts +++ b/release-tooling/contract-version.ts @@ -15,7 +15,7 @@ export function readHostApiVersion(source: string): string | null { // 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 }; + return { error: "HOST_API_VERSION not found", ok: false }; } const t = SEMVER.exec(tag); if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false }; @@ -31,14 +31,16 @@ export function checkTagMatchesContract(tag: string, hostApiVersion: string | nu }; } -// CLI: node auto-release/contract-version.ts → exits 1 on mismatch. +// CLI: node release-tooling/contract-version.ts → exits 1 on +// mismatch. `-` reads the source on stdin, so a caller checking a ref other than its checkout +// (`git show origin/main:… | …`) needs no scratch file in the workspace. if (process.argv[1]?.endsWith("/contract-version.ts")) { - const [, , tag, pluginPath] = process.argv; + const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv; const { readFileSync } = await import("node:fs"); - const source = readFileSync(pluginPath ?? "src/plugin-host/plugin.ts", "utf8"); + const source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8"); const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source)); if (!result.ok) { - process.stderr.write(`${result.error}\n`); + process.stderr.write(`${pluginPath}: ${result.error}\n`); process.exit(1); } process.stdout.write(`${tag} matches HOST_API_VERSION\n`); diff --git a/README-dockerhub.md b/release-tooling/dockerhub-overview.md.tmpl similarity index 99% rename from README-dockerhub.md rename to release-tooling/dockerhub-overview.md.tmpl index 6921772..51a9047 100644 --- a/README-dockerhub.md +++ b/release-tooling/dockerhub-overview.md.tmpl @@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`: import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, diff --git a/release-tooling/dockerhub-overview.test.ts b/release-tooling/dockerhub-overview.test.ts index 95e8564..1b6c15b 100644 --- a/release-tooling/dockerhub-overview.test.ts +++ b/release-tooling/dockerhub-overview.test.ts @@ -13,8 +13,8 @@ test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes cle 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"); +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"); 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. diff --git a/release-tooling/dockerhub-overview.ts b/release-tooling/dockerhub-overview.ts index 9d2b4d5..8d124f7 100644 --- a/release-tooling/dockerhub-overview.ts +++ b/release-tooling/dockerhub-overview.ts @@ -1,6 +1,6 @@ -// 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. +// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl. 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 release, so they cannot. const HUB = "https://hub.docker.com/v2"; @@ -18,16 +18,18 @@ async function main(): Promise { const { readFileSync } = await import("node:fs"); const repo = process.env["DOCKERHUB_REPO"]; const user = process.env["DOCKERHUB_USER"]; - const token = process.env["DOCKERHUB_TOKEN"]; + const token = process.env["DOCKERHUB_OVERVIEW_TOKEN"]; if (!version || !repo || !user || !token) { - process.stderr.write("usage: dockerhub-overview.ts ; needs DOCKERHUB_REPO/USER/TOKEN\n"); + process.stderr.write( + "usage: dockerhub-overview.ts ; needs DOCKERHUB_REPO, DOCKERHUB_USER, DOCKERHUB_OVERVIEW_TOKEN\n", + ); return 1; } - const body = renderOverview(readFileSync("README-dockerhub.md", "utf8"), version); + const body = renderOverview(readFileSync("release-tooling/dockerhub-overview.md.tmpl", "utf8"), version); const leftover = leftoverPlaceholders(body); if (leftover.length > 0) { - process.stderr.write(`README-dockerhub.md has unrendered placeholders: ${leftover.join(", ")}\n`); + process.stderr.write(`release-tooling/dockerhub-overview.md.tmpl has unrendered placeholders: ${leftover.join(", ")}\n`); return 1; } @@ -56,8 +58,8 @@ async function main(): Promise { 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" + ? "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" : ""), ); return 1; diff --git a/auto-release/next-version.test.ts b/release-tooling/next-version.test.ts similarity index 100% rename from auto-release/next-version.test.ts rename to release-tooling/next-version.test.ts diff --git a/auto-release/next-version.ts b/release-tooling/next-version.ts similarity index 95% rename from auto-release/next-version.ts rename to release-tooling/next-version.ts index ecc6ad6..2f8fb7f 100644 --- a/auto-release/next-version.ts +++ b/release-tooling/next-version.ts @@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string { return `v${major}.${minor}.${patch + 1}`; } -// CLI: node auto-release/next-version.ts [updateType...] → prints the next tag. +// CLI: node release-tooling/next-version.ts [updateType...] → prints the next tag. if (process.argv[1]?.endsWith("/next-version.ts")) { const [, , latestTag, ...updateTypes] = process.argv; process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes))); diff --git a/renovate.json b/renovate.json index 3494f80..1563568 100644 --- a/renovate.json +++ b/renovate.json @@ -53,8 +53,8 @@ }, { "customType": "regex", - "description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)", - "managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"], + "description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)", + "managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"], "matchStrings": ["\\snode:(?[0-9][^\\s\"']*)"], "depNameTemplate": "node", "datasourceTemplate": "docker" diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index 6fde120..9ddc473 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -20,7 +20,7 @@ import { AUTH_FLOWS } from "../auth/flow-view.ts"; // HOST_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the // host derives both from the plugin's folder name. const scheduling: PluginManifest = definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", hooks: { onBoot: () => {} }, nav: [{ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 00dbe97..7f94d16 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -11,7 +11,7 @@ import type { StorageCredentials } from "./storage.ts"; // The Plainpages release this contract ships in — one version, not a second one to track. Its // major.minor must equal the release tag's; `release.yml` refuses to promote a tag that disagrees. // The patch digit may lag, since checkApiVersion ignores patch and auto-release cuts patches itself. -export const HOST_API_VERSION = "0.1.0"; +export const HOST_API_VERSION = "0.2.0"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; @@ -160,7 +160,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` }; } if (plugin.minor < host.minor) { - return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — newer features available` }; + return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — built against an older release` }; } return { level: "ok", message: `apiVersion ${pluginVersion}` }; } diff --git a/src/server.ts b/src/server.ts index b489926..1443f46 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { createApp } from "./http/app.ts"; import { loadConfig } from "./config.ts"; import { createDenylist } from "./auth/denylist.ts"; import { discoverPlugins } from "./plugin-host/discovery.ts"; +import { HOST_API_VERSION } from "./plugin-host/plugin.ts"; import { withTimeout } from "./auth/fetch-timeout.ts"; import { runBootHooks } from "./plugin-host/hooks.ts"; import { createHydraAdmin } from "./auth/hydra-admin.ts"; @@ -91,7 +92,7 @@ const server = createApp({ plugins, secureCookies: config.secureCookies, }).listen(config.port, () => { - log.info("listening", { port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` }); + log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` }); }); // Drain in-flight requests on container stop instead of cutting them mid-response, then flush any diff --git a/tsconfig.json b/tsconfig.json index b903c3e..c5569e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "include": ["auto-release", "config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "release-tooling", "src"] + "include": ["config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "release-tooling", "src"] } diff --git a/views/index.ejs b/views/index.ejs index 118c8e2..12ed83a 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -14,7 +14,7 @@

${t("dashboard.starter.intro")}

${t("dashboard.starter.replace")}

export default definePlugin({
-  apiVersion: "0.1.0",
+  apiVersion: "0.2.0",
   // view names plugins/<id>/views/<view>.ejs, rendered in this same shell
   dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
 });
-- 2.52.0 From b708aab98157f2493d1e9b9de80d44597ced89cf Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 11:21:42 +0200 Subject: [PATCH 04/15] =?UTF-8?q?Keep=20the=20release=20at=20v0.1.0=20?= =?UTF-8?q?=E2=80=94=20nothing=20consumed=20the=20old=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++++------------------ examples/plugins/admin/plugin.ts | 2 +- examples/plugins/scheduling/plugin.ts | 2 +- release-tooling/contract-version.test.ts | 23 +++++++++++----------- release-tooling/dockerhub-overview.md.tmpl | 2 +- src/plugin-host/plugin.test.ts | 2 +- src/plugin-host/plugin.ts | 2 +- views/index.ejs | 2 +- 8 files changed, 21 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 91866b7..ebe5110 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin. import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ - apiVersion: "0.2.0", + apiVersion: "0.1.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, @@ -348,7 +348,7 @@ import { definePlugin } from "@plainpages/plugin-api"; import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ - apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning) + apiVersion: "0.1.0", // semver string of the host contract this plugin was built against (see Versioning) // Nav fragment, merged into the global menu and permission-filtered per user. // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). @@ -468,7 +468,7 @@ import { definePlugin } from "@plainpages/plugin-api"; import { landing, board } from "./pages.ts"; export default definePlugin({ - apiVersion: "0.2.0", + apiVersion: "0.1.0", home: landing, // owns "/" — the public front page dashboard: board, // owns "/dashboard" — the post-login app home }); @@ -751,7 +751,7 @@ import { definePlugin } from "@plainpages/plugin-api"; let sql: ReturnType; export default definePlugin({ - apiVersion: "0.2.0", + apiVersion: "0.1.0", storage: true, hooks: { onBoot: async (boot) => { @@ -1513,21 +1513,6 @@ box. The web app waits for Kratos + Keto healthy *and* the bootstrap to finish b ## Upgrading -### 0.1.x → 0.2.0: `apiVersion` now names the Plainpages release - -`0.1.0` shipped a host reporting contract version `1.0.0` — a second number that tracked the plugin -contract separately from the release. There is only one number now, and it is the release: a host on -`0.2.0` reports `0.2.0`. Every plugin must say so, or discovery refuses it by version and **aborts -boot**: - -```ts -// plugins//plugin.ts -apiVersion: "0.2.0", // was "1.0.0" -``` - -Re-copy anything taken from `examples/` (below) to pick this up. Nothing else in the contract -changed, so a plugin that boots after the edit needs no further work. - **Re-copy your drop-in plugins.** Anything under `plugins/` is *your* copy — the host never updates it. A plugin copied from `examples/` is still the old one after you pull, and the host may have tightened a manifest rule since. Discovery fails loud at boot rather than running a plugin it can't diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index ec19a8b..ece09d0 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -26,7 +26,7 @@ const groups = on("groups"); const clients = on("oauth2-clients"); export default definePlugin({ - apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION + apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION nav: [ADMIN_NAV], diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index 2bf86eb..9b3cb1a 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -11,7 +11,7 @@ const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstrea const upstream = createUpstream(upstreamUrl); export default definePlugin({ - apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION + apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION // onBoot runs after discovery, before the server listens: validate the plugin's own config so a // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. diff --git a/release-tooling/contract-version.test.ts b/release-tooling/contract-version.test.ts index c48a994..8f3d780 100644 --- a/release-tooling/contract-version.test.ts +++ b/release-tooling/contract-version.test.ts @@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => { // Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that // makes an accidental edit fail here rather than at release time. - assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0"); + assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0"); }); test("every author-facing apiVersion sample matches the shipped contract", () => { @@ -38,22 +38,21 @@ test("every author-facing apiVersion sample matches the shipped contract", () => }); test("checkTagMatchesContract: major.minor must agree, patch may lag", () => { - assert.equal(checkTagMatchesContract("v0.2.0", "0.2.0").ok, true); - assert.equal(checkTagMatchesContract("v0.2.7", "0.2.0").ok, true); // auto-release cut patches - assert.equal(checkTagMatchesContract("0.2.0", "0.2.0").ok, true); // bare tag, no v - assert.equal(checkTagMatchesContract("v0.3.0", "0.2.0").ok, false); // plugin-visible, needs a bump - assert.equal(checkTagMatchesContract("v0.1.0", "0.2.0").ok, false); // the previously released line - assert.equal(checkTagMatchesContract("v1.0.0", "0.2.0").ok, false); + 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.3.0", "0.2.0"); + 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\.3\.0/); + 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.2.0", null).ok, false); // constant not found - assert.equal(checkTagMatchesContract("nope", "0.2.0").ok, false); - assert.equal(checkTagMatchesContract("v0.2.0", "1.0").ok, false); + 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); }); diff --git a/release-tooling/dockerhub-overview.md.tmpl b/release-tooling/dockerhub-overview.md.tmpl index 51a9047..6921772 100644 --- a/release-tooling/dockerhub-overview.md.tmpl +++ b/release-tooling/dockerhub-overview.md.tmpl @@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`: import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ - apiVersion: "0.2.0", + apiVersion: "0.1.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index 9ddc473..6fde120 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -20,7 +20,7 @@ import { AUTH_FLOWS } from "../auth/flow-view.ts"; // HOST_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the // host derives both from the plugin's folder name. const scheduling: PluginManifest = definePlugin({ - apiVersion: "0.2.0", + apiVersion: "0.1.0", hooks: { onBoot: () => {} }, nav: [{ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 7f94d16..493d03c 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -11,7 +11,7 @@ import type { StorageCredentials } from "./storage.ts"; // The Plainpages release this contract ships in — one version, not a second one to track. Its // major.minor must equal the release tag's; `release.yml` refuses to promote a tag that disagrees. // The patch digit may lag, since checkApiVersion ignores patch and auto-release cuts patches itself. -export const HOST_API_VERSION = "0.2.0"; +export const HOST_API_VERSION = "0.1.0"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; diff --git a/views/index.ejs b/views/index.ejs index 12ed83a..118c8e2 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -14,7 +14,7 @@

${t("dashboard.starter.intro")}

${t("dashboard.starter.replace")}

export default definePlugin({
-  apiVersion: "0.2.0",
+  apiVersion: "0.1.0",
   // view names plugins/<id>/views/<view>.ejs, rendered in this same shell
   dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
 });
-- 2.52.0 From 582026488566a317e1cf20953a5ba3aac8208225 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 11:24:16 +0200 Subject: [PATCH 05/15] Trim the prose to what is true now --- .gitea/workflows/release.yml | 2 +- AGENTS.md | 8 ++++---- README.md | 14 +++++++------- release-tooling/dockerhub-overview.test.ts | 2 +- release-tooling/dockerhub-overview.ts | 4 ++-- renovate.json | 2 +- src/plugin-host/plugin.ts | 6 +++--- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9697a85..6b9fa78 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: overview_version: - description: 'Version the overview should tell adopters to pull (e.g. 0.2.0)' + description: 'Version the overview should tell adopters to pull (e.g. 0.1.0)' required: true jobs: diff --git a/AGENTS.md b/AGENTS.md index de88490..7eeebec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -293,7 +293,7 @@ Revisit only if the stated reason stops holding. docs and skip the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a *text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test - guards it, so renaming it back would let a broken `{{VERSION}}` merge with its own guard skipped. + guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard skipped. - **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that @@ -353,9 +353,9 @@ one-time setup. A file-map or table row gets a clause, not a paragraph. trailer, so a dependency added anywhere else never escalates the release version and nothing fails to say so. A new manifest, compose file, custom manager or dep type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing and let it ride the next patch. -- **`HOST_API_VERSION` *is* the release version — one number, not two.** Its `major.minor` must equal - the release tag's, and both release paths refuse a tag that disagrees - (`release-tooling/contract-version.ts`). The patch digit may lag on purpose: `checkApiVersion` +- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and + both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch + digit may lag on purpose: `checkApiVersion` ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release` stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge diff --git a/README.md b/README.md index ebe5110..a62ea15 100644 --- a/README.md +++ b/README.md @@ -594,9 +594,9 @@ works without editing host config. ### Contract versioning Each manifest declares `apiVersion` — a **semver** string naming the **Plainpages release** it was -built against. There is no second number to track: the host's `HOST_API_VERSION` *is* its release -version, so a plugin author reads one version off the image they run and writes it down. Both release -paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift. +built against. The host's `HOST_API_VERSION` *is* its release version, so a plugin author reads one +version off the image they run and writes it down — there is no separate contract number. Both +release paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift. Patch releases are invisible here — `checkApiVersion` ignores the patch digit, which is what lets dependency updates ship continuously without touching any plugin. At discovery the host parses both @@ -612,10 +612,10 @@ provider/consumer semantics in `checkApiVersion`: | missing / not a valid semver | `refuse` | **abort boot** — must be declared | The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies -the caret-style compatibility. Note what a **minor** means now that one digit carries the whole -release: either the plugin contract changed, or a dependency moved far enough to warrant one. A -`warn` therefore says "built against an older release", not "new plugin features exist" — check the -Upgrading section for that release before assuming there is anything to adopt. While Plainpages is `0.x` every release shares major `0`, so a plugin +the caret-style compatibility. One digit carries the whole release, so a **minor** means either the +plugin contract changed or a dependency moved far enough to warrant one. A `warn` therefore says +"built against an older release" rather than promising new plugin features; check that release's +notes before assuming there is anything to adopt. While Plainpages is `0.x` every release shares major `0`, so a plugin built against `0.1.0` still loads on a `0.9.0` host with a `warn`; reaching `1.0.0` refuses everything built against `0.x`, which is the point of that milestone. diff --git a/release-tooling/dockerhub-overview.test.ts b/release-tooling/dockerhub-overview.test.ts index 1b6c15b..56571cf 100644 --- a/release-tooling/dockerhub-overview.test.ts +++ b/release-tooling/dockerhub-overview.test.ts @@ -17,6 +17,6 @@ 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"); 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. + // 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+(? Date: Sat, 22 Aug 2026 11:27:35 +0200 Subject: [PATCH 06/15] Gate the overview's manual trigger on the same contract check as a tag --- .gitea/workflows/release.yml | 8 +++++++- README.md | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6b9fa78..e57c8dc 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -64,7 +64,8 @@ jobs: docker logout docker.io # 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 must not report the release itself as failed. + # 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') needs: [retag-image] @@ -80,6 +81,11 @@ jobs: INPUT_VERSION: ${{ inputs.overview_version }} run: | 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. + 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 \ -e DOCKERHUB_OVERVIEW_TOKEN -e DOCKERHUB_REPO -e DOCKERHUB_USER \ node:24.19.0-alpine3.24 \ diff --git a/README.md b/README.md index a62ea15..430f981 100644 --- a/README.md +++ b/README.md @@ -1418,8 +1418,10 @@ whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to se [`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. It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub -outage must not report a good release as failed; and the page has its own door — run the workflow -manually with an `overview_version` input to republish it without cutting a release. It uses +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 `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. -- 2.52.0 From 41084441fff9cfc3a8f084a52840d77b6ff30944 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 11:45:46 +0200 Subject: [PATCH 07/15] Manage the published quick start's pins, and fail closed on every release-tooling edge --- .gitea/workflows/release.yml | 22 +++--- README.md | 8 ++- release-tooling/contract-version.ts | 19 +++-- release-tooling/dockerhub-overview.md.tmpl | 4 +- release-tooling/dockerhub-overview.test.ts | 34 +++++++-- release-tooling/dockerhub-overview.ts | 83 ++++++++++++++-------- renovate.json | 7 ++ 7 files changed, 121 insertions(+), 56 deletions(-) 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)", -- 2.52.0 From 6c078561599b5f283de882fd7ca332a8ca66f3df Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 11:58:28 +0200 Subject: [PATCH 08/15] Accept the README-only gate skip, and say why it is safe --- AGENTS.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7eeebec..875a788 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,13 +287,17 @@ Revisit only if the stated reason stops holding. console message only appears in the engine that renders the page (`ORY_FREE` in `e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend, so widening them means a stack per engine. -- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root** — no test, build step or - workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: - rename detection names only the destination, so `git mv src/app.ts notes.md` would otherwise read as - docs and skip the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a +- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in + `ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so + `git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that + was gone. `src/ci-gate.test.ts` locks the flags as a *text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test - guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard skipped. + guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard + skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts` + checks its `apiVersion` samples — and a README-only change skips that check; accepted, because + those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are + gated. **Valid while no markdown file is rendered or executed.** - **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that -- 2.52.0 From d62c7faabdc3656ae184b27206d1e01f63e793ca Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 12:01:35 +0200 Subject: [PATCH 09/15] Scope the sidecar trailer by package so a mixed branch cannot lose it --- .gitea/workflows/release.yml | 1 + README.md | 2 +- release-tooling/dockerhub-overview.test.ts | 2 +- release-tooling/dockerhub-overview.ts | 8 +++++++- renovate.json | 6 ++++++ src/plugin-host/plugin.test.ts | 2 +- 6 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 03cd8c6..1ed5109 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -83,6 +83,7 @@ jobs: run: | set -euo pipefail VERSION=${INPUT_VERSION:-${GIT_TAG#v}} + VERSION=${VERSION#v} # 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 diff --git a/README.md b/README.md index 414fecd..969e35b 100644 --- a/README.md +++ b/README.md @@ -606,7 +606,7 @@ provider/consumer semantics in `checkApiVersion`: | Plugin `apiVersion` vs host | Result | Host action | | --- | --- | --- | | same major, same minor (patch ignored) | `ok` | load | -| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist | +| same major, plugin minor **<** host minor | `warn` | load, log — built against an older release; check that release's notes | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host | | different major | `refuse` | **abort boot** — incompatible contract | | missing / not a valid semver | `refuse` | **abort boot** — must be declared | diff --git a/release-tooling/dockerhub-overview.test.ts b/release-tooling/dockerhub-overview.test.ts index d383a1e..f0153b0 100644 --- a/release-tooling/dockerhub-overview.test.ts +++ b/release-tooling/dockerhub-overview.test.ts @@ -28,8 +28,8 @@ test("the quick start's sidecars are pinned to the same versions this repo runs" 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")), + ...pins(readFileSync("compose.yml", "utf8")), // production wins: the template is the prod quick start ]); const published = pins(template()); assert.ok(published.size > 0, "the template should pin sidecars"); diff --git a/release-tooling/dockerhub-overview.ts b/release-tooling/dockerhub-overview.ts index 6d0b86f..a7e06f0 100644 --- a/release-tooling/dockerhub-overview.ts +++ b/release-tooling/dockerhub-overview.ts @@ -60,7 +60,13 @@ async function main(): Promise { if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`); const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl"); - const body = renderOverview(readFileSync(templatePath, "utf8"), version); + let template = ""; + try { + template = readFileSync(templatePath, "utf8"); + } catch (err) { + return fail(`${templatePath}: ${err instanceof Error ? err.message : String(err)}`); + } + const body = renderOverview(template, version); const leftover = leftoverPlaceholders(body); if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`); diff --git a/renovate.json b/renovate.json index 1235384..86d345a 100644 --- a/renovate.json +++ b/renovate.json @@ -24,6 +24,12 @@ "matchManagers": ["docker-compose"], "commitBody": "Release-Bump: {{{updateType}}}" }, + { + "description": "The production sidecars, wherever they are pinned — compose.yml and the published quick start move in one branch, so the trailer must not depend on which upgrade sorts first. mailpit is dev-only and stays out", + "matchDatasources": ["docker"], + "matchPackageNames": ["oryd/hydra", "oryd/keto", "oryd/kratos", "postgres"], + "commitBody": "Release-Bump: {{{updateType}}}" + }, { "description": "node is pinned to one version across Dockerfile, dev, E2E and CI, so Renovate moves them in a single branch whose commitBody would otherwise depend on upgrade order — the Dockerfile copy ships, so any node bump is a product change", "matchDatasources": ["docker"], diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index 6fde120..ebd9330 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -20,7 +20,7 @@ import { AUTH_FLOWS } from "../auth/flow-view.ts"; // HOST_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the // host derives both from the plugin's folder name. const scheduling: PluginManifest = definePlugin({ - apiVersion: "0.1.0", + apiVersion: "1.0.0", hooks: { onBoot: () => {} }, nav: [{ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], -- 2.52.0 From d8b7a2d64a2a5860b40b2e22d5b60fc87a2bf49c Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 12:03:18 +0200 Subject: [PATCH 10/15] Keep the proven always() guard on the overview job --- .gitea/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1ed5109..1b3f84f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -68,7 +68,7 @@ jobs: docker logout docker.io || true publish-overview: - if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success') }} + if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success') needs: [retag-image] runs-on: docker-host steps: -- 2.52.0 From 2d62553a10fa72fbbbd1a916472550aa1e0d3157 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 12:13:41 +0200 Subject: [PATCH 11/15] Refuse a 0.x minor mismatch, and republish the overview from the named release's tree --- .gitea/workflows/release.yml | 6 +++++- README.md | 15 +++++++++------ src/plugin-host/plugin.test.ts | 3 +++ src/plugin-host/plugin.ts | 6 ++++++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1b3f84f..ddb4477 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: overview_version: - description: 'Version the overview should tell adopters to pull (e.g. 0.1.0)' + description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)' required: true jobs: @@ -72,7 +72,11 @@ jobs: needs: [retag-image] runs-on: docker-host steps: + # Publish the named release's own tree, so the page never pairs one Plainpages tag with another + # release's sidecar pins. A version that was never released fails here. - uses: actions/checkout@v7.0.1 + with: + ref: ${{ inputs.overview_version && format('refs/tags/v{0}', inputs.overview_version) || github.ref }} - name: Publish the Docker Hub overview env: DOCKERHUB_OVERVIEW_TOKEN: ${{ secrets.DOCKERHUB_OVERVIEW_TOKEN }} diff --git a/README.md b/README.md index 969e35b..fef3f21 100644 --- a/README.md +++ b/README.md @@ -607,17 +607,20 @@ provider/consumer semantics in `checkApiVersion`: | --- | --- | --- | | same major, same minor (patch ignored) | `ok` | load | | same major, plugin minor **<** host minor | `warn` | load, log — built against an older release; check that release's notes | +| **major `0`**, any minor mismatch | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host | | different major | `refuse` | **abort boot** — incompatible contract | | missing / not a valid semver | `refuse` | **abort boot** — must be declared | The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies -the caret-style compatibility. One digit carries the whole release, so a **minor** means either the -plugin contract changed or a dependency moved far enough to warrant one. A `warn` therefore says -"built against an older release" rather than promising new plugin features; check that release's -notes before assuming there is anything to adopt. While Plainpages is `0.x` every release shares major `0`, so a plugin -built against `0.1.0` still loads on a `0.9.0` host with a `warn`; reaching `1.0.0` refuses everything -built against `0.x`, which is the point of that milestone. +the compatibility. One digit carries the whole release, so a **minor** means either the plugin +contract changed or a dependency moved far enough to warrant one. + +While Plainpages is `0.x` the major stays `0`, which leaves the minor as the only slot a breaking +change can use — so a minor mismatch is refused rather than warned: a plugin declaring `0.1.0` will +not boot on a `0.2.0` host until it is rebuilt against it. Once `1.0.0` lands the minor becomes +additive and the `warn` row applies, saying "built against an older release" rather than promising +new features. ### Conflict rules diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index ebd9330..ceae298 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -86,6 +86,9 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer) assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older) + assert.equal(checkApiVersion("0.1.0", "0.1.9").level, "ok"); // pre-1.0 patch is still ignored + assert.equal(checkApiVersion("0.1.0", "0.2.0").level, "refuse"); // pre-1.0 the minor IS the breaking slot + assert.equal(checkApiVersion("0.2.0", "0.1.0").level, "refuse"); for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) { assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`); } diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index c544d39..9ff9f5a 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -156,6 +156,12 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO if (plugin.major !== host.major) { return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — incompatible major` }; } + // Pre-1.0 the major is pinned at 0 until the 1.0.0 milestone, so a breaking change can only land + // as a minor (release-tooling/next-version.ts shifts every level down). Treating that as additive + // would let a stale plugin boot and fail at runtime instead of at discovery. + if (host.major === 0 && plugin.minor !== host.minor) { + return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — pre-1.0 a minor is a contract break, rebuild against ${hostVersion}` }; + } if (plugin.minor > host.minor) { return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` }; } -- 2.52.0 From 716e0f50a71bcf69a1d4fa94d1b258accc464e61 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 12:27:43 +0200 Subject: [PATCH 12/15] Give each version mismatch its own remedy, and stop publishing a moving bare-major tag --- .gitea/workflows/release.yml | 15 +++++++++++--- AGENTS.md | 10 +++++----- README.md | 23 ++++++++++------------ release-tooling/contract-version.ts | 7 ++----- release-tooling/dockerhub-overview.md.tmpl | 2 +- release-tooling/dockerhub-overview.ts | 2 +- src/plugin-host/plugin.test.ts | 2 +- src/plugin-host/plugin.ts | 14 +++++-------- 8 files changed, 37 insertions(+), 38 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ddb4477..471bcff 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -36,7 +36,11 @@ jobs: printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin docker pull "$REPO:$COMMIT" \ || { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; } - for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do + # No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move + # across one and abort boot for everything tracking it. `:0.1` only moves across patches. + TAGS="$VERSION ${VERSION%.*} latest" + if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi + for TAG in $TAGS; do docker tag "$REPO:$COMMIT" "$REPO:$TAG" docker push "$REPO:$TAG" done @@ -54,7 +58,9 @@ jobs: [ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \ || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; } printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin - for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do + TAGS="$VERSION ${VERSION%.*} latest" + if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi + for TAG in $TAGS; do docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG" docker push "$DOCKERHUB_IMAGE:$TAG" done @@ -72,11 +78,14 @@ jobs: needs: [retag-image] runs-on: docker-host steps: + - uses: actions/checkout@v7.0.1 + if: github.event_name == 'push' # Publish the named release's own tree, so the page never pairs one Plainpages tag with another # release's sidecar pins. A version that was never released fails here. - uses: actions/checkout@v7.0.1 + if: github.event_name == 'workflow_dispatch' with: - ref: ${{ inputs.overview_version && format('refs/tags/v{0}', inputs.overview_version) || github.ref }} + ref: refs/tags/v${{ inputs.overview_version }} - name: Publish the Docker Hub overview env: DOCKERHUB_OVERVIEW_TOKEN: ${{ secrets.DOCKERHUB_OVERVIEW_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 875a788..ea2c00a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -352,11 +352,11 @@ one-time setup. A file-map or table row gets a clause, not a paragraph. - Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images by tag. -- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist* — only - the root `package.json`'s runtime deps, the `Dockerfile` base and `compose.yml`'s services carry the - trailer, so a dependency added anywhere else never escalates the release version and nothing fails to - say so. A new manifest, compose file, custom manager or dep type is a decision: can it reach a - running Plainpages? If yes it needs a rule; if no, record nothing and let it ride the next patch. +- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its + rules name exactly what carries the trailer, so a dependency outside them never escalates the + release version and nothing fails to say so. A new manifest, compose file, custom manager or dep + type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing + and let it ride the next patch. - **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch digit may lag on purpose: `checkApiVersion` diff --git a/README.md b/README.md index fef3f21..e2c8540 100644 --- a/README.md +++ b/README.md @@ -606,8 +606,8 @@ provider/consumer semantics in `checkApiVersion`: | Plugin `apiVersion` vs host | Result | Host action | | --- | --- | --- | | same major, same minor (patch ignored) | `ok` | load | +| **major `0`**, plugin minor **<** host minor | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot | | same major, plugin minor **<** host minor | `warn` | load, log — built against an older release; check that release's notes | -| **major `0`**, any minor mismatch | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host | | different major | `refuse` | **abort boot** — incompatible contract | | missing / not a valid semver | `refuse` | **abort boot** — must be declared | @@ -616,11 +616,6 @@ The plugin pins one exact version (no ranges, per the project's pinning rules); the compatibility. One digit carries the whole release, so a **minor** means either the plugin contract changed or a dependency moved far enough to warrant one. -While Plainpages is `0.x` the major stays `0`, which leaves the minor as the only slot a breaking -change can use — so a minor mismatch is refused rather than warned: a plugin declaring `0.1.0` will -not boot on a `0.2.0` host until it is rebuilt against it. Once `1.0.0` lands the minor becomes -additive and the `warn` row applies, saying "built against an older release" rather than promising -new features. ### Conflict rules @@ -1384,7 +1379,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs | Workflow | Trigger | Does | | --- | --- | --- | | `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image | -| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`, sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger | +| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger | | `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | | `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head | | `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged | @@ -1409,8 +1404,10 @@ pattern-based org cleanup rule for this package — its age/count heuristics can release tags and would delete images the workflow protects. **Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs -`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `1`, `latest`; -nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no +`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and — +once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the +gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract +break and a moving `:0` would carry one. It fails loud if no hash image exists — release tags must point at a commit that went through the gate. The same four tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. @@ -1448,15 +1445,15 @@ exact. Each PR runs the normal gate on its `renovate/*` branch and automerges on `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and **skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a -`Release-Bump: ` trailer onto the updates that reach a running Plainpages — the root -`package.json`'s runtime dependencies, the image base, and `compose.yml`'s services — and +`Release-Bump: ` trailer onto the updates that reach a running Plainpages — the rules in +[`renovate.json`](renovate.json) name them — and [`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release version, an update big enough to reach a **minor** stops the job rather than tagging: bump `HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since `nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump, -so the trailer is an allowlist in [`renovate.json`](renovate.json): a devDependency, E2E or CI-only -bump carries none and rides the next patch release instead of escalating it. It is **tag-only**: the tag hands off to +so the trailer is an allowlist: an update outside those rules carries none and rides the next patch +release instead of escalating it. It is **tag-only**: the tag hands off to `release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here. diff --git a/release-tooling/contract-version.ts b/release-tooling/contract-version.ts index 5248ce7..88e9b17 100644 --- a/release-tooling/contract-version.ts +++ b/release-tooling/contract-version.ts @@ -1,6 +1,4 @@ -// 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. +import { readFileSync } from "node:fs"; export type ContractCheck = { ok: true } | { ok: false; error: string }; @@ -35,13 +33,12 @@ export function checkTagMatchesContract(tag: string, hostApiVersion: string | nu // (`git show origin/main:… | …`) needs no scratch file in the workspace. if (process.argv[1]?.endsWith("/contract-version.ts")) { const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv; - const { readFileSync } = await import("node:fs"); 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); + process.exitCode = 1; } const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source)); if (!result.ok) { diff --git a/release-tooling/dockerhub-overview.md.tmpl b/release-tooling/dockerhub-overview.md.tmpl index 8813f06..aa2da84 100644 --- a/release-tooling/dockerhub-overview.md.tmpl +++ b/release-tooling/dockerhub-overview.md.tmpl @@ -10,7 +10,7 @@ one; the host itself is stateless, and there is no build step. ## Tags -`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build. +`X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build. Pin the exact `X.Y.Z` you deploy. ## Quick start diff --git a/release-tooling/dockerhub-overview.ts b/release-tooling/dockerhub-overview.ts index a7e06f0..4dca3fe 100644 --- a/release-tooling/dockerhub-overview.ts +++ b/release-tooling/dockerhub-overview.ts @@ -100,5 +100,5 @@ async function main(): Promise { } if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) { - process.exit(await main()); + process.exitCode = await main(); } diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index ceae298..d4b123e 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -88,7 +88,7 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older) assert.equal(checkApiVersion("0.1.0", "0.1.9").level, "ok"); // pre-1.0 patch is still ignored assert.equal(checkApiVersion("0.1.0", "0.2.0").level, "refuse"); // pre-1.0 the minor IS the breaking slot - assert.equal(checkApiVersion("0.2.0", "0.1.0").level, "refuse"); + assert.match(checkApiVersion("0.2.0", "0.1.0").message, /upgrade the host/); // ahead of the host, even pre-1.0 for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) { assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`); } diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 9ff9f5a..2fed523 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -8,9 +8,7 @@ import type { RequestContext } from "../http/context.ts"; import type { NavNode } from "../ui/nav.ts"; import type { StorageCredentials } from "./storage.ts"; -// The Plainpages release this contract ships in. Its major.minor must equal the release tag's, and -// both release paths refuse a tag that disagrees. The patch digit may lag: checkApiVersion ignores -// patch, and auto-release cuts patch releases with no commit to bump this in. +// The Plainpages release this contract ships in — see README → Contract versioning. export const HOST_API_VERSION = "0.1.0"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; @@ -156,16 +154,14 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO if (plugin.major !== host.major) { return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — incompatible major` }; } - // Pre-1.0 the major is pinned at 0 until the 1.0.0 milestone, so a breaking change can only land - // as a minor (release-tooling/next-version.ts shifts every level down). Treating that as additive - // would let a stale plugin boot and fail at runtime instead of at discovery. - if (host.major === 0 && plugin.minor !== host.minor) { - return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — pre-1.0 a minor is a contract break, rebuild against ${hostVersion}` }; - } if (plugin.minor > host.minor) { return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` }; } if (plugin.minor < host.minor) { + // Pre-1.0 the major is pinned at 0, so a minor is the only slot a breaking change can use. + if (host.major === 0) { + return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — pre-1.0 a minor is a contract break, rebuild against ${hostVersion}` }; + } return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — built against an older release` }; } return { level: "ok", message: `apiVersion ${pluginVersion}` }; -- 2.52.0 From 734cdf689295934a038c0304e048699823a30cb4 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 14:21:14 +0200 Subject: [PATCH 13/15] Name the scope the overview actually needs --- README.md | 2 +- release-tooling/dockerhub-overview.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e2c8540..838ce49 100644 --- a/README.md +++ b/README.md @@ -1463,7 +1463,7 @@ the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never tou | --- | --- | | `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. | | `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a read/write token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT can push to every repo under it). | -| `DOCKERHUB_OVERVIEW_TOKEN` (secret) | A Docker Hub PAT that may **edit repository metadata**, used only to publish the overview. Separate from `DOCKERHUB_TOKEN` so the image-push credential stays narrow; without it the `publish-overview` job fails and the released images are unaffected. | +| `DOCKERHUB_OVERVIEW_TOKEN` (secret) | A Docker Hub PAT with **read/write/delete** scope — editing the overview needs delete, pushing images does not. Kept separate so that scope never reaches `docker login`, which writes it to the runner's shared config; without it the `publish-overview` job fails and the released images are unaffected. | | `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. | | `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. | | `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. | diff --git a/release-tooling/dockerhub-overview.ts b/release-tooling/dockerhub-overview.ts index 4dca3fe..24da29a 100644 --- a/release-tooling/dockerhub-overview.ts +++ b/release-tooling/dockerhub-overview.ts @@ -90,8 +90,8 @@ async function main(): Promise { return fail( `Docker Hub overview PATCH failed: ${res.status} ${res.text}` + (res.status === 403 - ? "\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)." + ? "\n403 means DOCKERHUB_OVERVIEW_TOKEN lacks the delete scope — editing the overview needs " + + "read/write/delete, which pushing images does not (README -> CI/CD)." : ""), ); } -- 2.52.0 From 42af07c255078b0bca072c487c6cab12512f5843 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 14:27:31 +0200 Subject: [PATCH 14/15] Use one Docker Hub credential for images and the overview --- .gitea/workflows/release.yml | 4 ++-- README.md | 6 ++---- release-tooling/dockerhub-overview.ts | 8 ++++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 471bcff..975d9c5 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -88,8 +88,8 @@ jobs: ref: refs/tags/v${{ inputs.overview_version }} - name: Publish the Docker Hub overview env: - DOCKERHUB_OVERVIEW_TOKEN: ${{ secrets.DOCKERHUB_OVERVIEW_TOKEN }} DOCKERHUB_REPO: ${{ github.repository }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} GIT_TAG: ${{ github.ref_name }} INPUT_VERSION: ${{ inputs.overview_version }} @@ -101,6 +101,6 @@ jobs: 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 \ - -e DOCKERHUB_OVERVIEW_TOKEN -e DOCKERHUB_REPO -e DOCKERHUB_USER \ + -e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \ node:24.19.0-alpine3.24 \ node release-tooling/dockerhub-overview.ts "$VERSION" diff --git a/README.md b/README.md index 838ce49..8ecec03 100644 --- a/README.md +++ b/README.md @@ -1424,8 +1424,7 @@ outage leaves the promotion green and the images untouched; and the page has its 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: 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. +the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope. **GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift. @@ -1462,8 +1461,7 @@ the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never tou | Actions var / secret | Value | | --- | --- | | `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. | -| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a read/write token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT can push to every repo under it). | -| `DOCKERHUB_OVERVIEW_TOKEN` (secret) | A Docker Hub PAT with **read/write/delete** scope — editing the overview needs delete, pushing images does not. Kept separate so that scope never reaches `docker login`, which writes it to the runner's shared config; without it the `publish-overview` job fails and the released images are unaffected. | +| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. | | `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. | | `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. | | `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. | diff --git a/release-tooling/dockerhub-overview.ts b/release-tooling/dockerhub-overview.ts index 24da29a..f637c99 100644 --- a/release-tooling/dockerhub-overview.ts +++ b/release-tooling/dockerhub-overview.ts @@ -49,11 +49,11 @@ async function main(): Promise { const [, , version] = process.argv; const repo = process.env["DOCKERHUB_REPO"]; const user = process.env["DOCKERHUB_USER"]; - const token = process.env["DOCKERHUB_OVERVIEW_TOKEN"]; + const token = process.env["DOCKERHUB_TOKEN"]; if (!version || !repo || !user || !token) { return fail( "usage: dockerhub-overview.ts ; needs DOCKERHUB_REPO, DOCKERHUB_USER and " + - "DOCKERHUB_OVERVIEW_TOKEN (README -> CI/CD)", + "DOCKERHUB_TOKEN (README -> CI/CD)", ); } // The page is public, so never render a version that resolves to no image. @@ -90,8 +90,8 @@ async function main(): Promise { return fail( `Docker Hub overview PATCH failed: ${res.status} ${res.text}` + (res.status === 403 - ? "\n403 means DOCKERHUB_OVERVIEW_TOKEN lacks the delete scope — editing the overview needs " + - "read/write/delete, which pushing images does not (README -> CI/CD)." + ? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " + + "read/write/delete, which pushing images alone does not (README -> CI/CD)." : ""), ); } -- 2.52.0 From 4849a7a3c5a84c67052120ee6a496865e547d118 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 21:58:14 +0200 Subject: [PATCH 15/15] Follow mailpit to v1.31.0 in the published quick start --- release-tooling/dockerhub-overview.md.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-tooling/dockerhub-overview.md.tmpl b/release-tooling/dockerhub-overview.md.tmpl index aa2da84..c38c5e7 100644 --- a/release-tooling/dockerhub-overview.md.tmpl +++ b/release-tooling/dockerhub-overview.md.tmpl @@ -131,7 +131,7 @@ services: # Catches Kratos' recovery/verification emails — UI on http://localhost:8025 mailpit: - image: axllent/mailpit:v1.30.7 + image: axllent/mailpit:v1.31.0 ports: - "8025:8025" restart: unless-stopped -- 2.52.0