Scope the release signal, collapse the contract onto the release version, publish the Hub overview #83

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