Make the plugin contract version the release version, and publish the Docker Hub overview from CI
CI / full-gate (push) Successful in 3m8s
CI / full-gate (push) Successful in 3m8s
This commit is contained in:
@@ -8,6 +8,14 @@ jobs:
|
|||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7.0.1
|
- 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
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
@@ -41,6 +49,17 @@ jobs:
|
|||||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
||||||
docker push "$DOCKERHUB_REPO:$TAG"
|
docker push "$DOCKERHUB_REPO:$TAG"
|
||||||
done
|
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
|
- name: Log out of the registries
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||||
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
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"
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
git tag "$NEXT" origin/main
|
git tag "$NEXT" origin/main
|
||||||
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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
|
- **`HOST_API_VERSION` *is* the release version — one number, not two.** Its `major.minor` must equal
|
||||||
contract's move independently). Bump it with every contract change, per the table in README →
|
the release tag's, and both release paths refuse a tag that disagrees
|
||||||
Contract versioning: major on a breaking one, minor on an additive one. **The contract surface
|
(`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
|
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
|
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 })`
|
that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })`
|
||||||
|
|||||||
+4
-4
@@ -22,7 +22,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: larvit/plainpages:0.1.0
|
image: larvit/plainpages:{{VERSION}}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
|
|
||||||
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
image: larvit/plainpages:0.1.0
|
image: larvit/plainpages:{{VERSION}}
|
||||||
command: node src/auth/bootstrap.ts
|
command: node src/auth/bootstrap.ts
|
||||||
depends_on:
|
depends_on:
|
||||||
kratos:
|
kratos:
|
||||||
@@ -143,7 +143,7 @@ volumes:
|
|||||||
Extract the Ory config the image ships, then start:
|
Extract the Ory config the image ships, then start:
|
||||||
|
|
||||||
```bash
|
```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
|
mkdir -p plugins
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "0.1.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "0.1.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ 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";
|
import { listThings, createThings } from "./handlers.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
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.
|
// 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).
|
// `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";
|
import { landing, board } from "./pages.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "0.1.0",
|
||||||
home: landing, // owns "/" — the public front page
|
home: landing, // owns "/" — the public front page
|
||||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||||
});
|
});
|
||||||
@@ -593,11 +593,15 @@ works without editing host config.
|
|||||||
|
|
||||||
### Contract versioning
|
### Contract versioning
|
||||||
|
|
||||||
Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built
|
Each manifest declares `apiVersion` — a **semver** string naming the **Plainpages release** it was
|
||||||
against — against the host's `HOST_API_VERSION`. The host bumps **major** on a breaking
|
built against. There is no second number to track: the host's `HOST_API_VERSION` *is* its release
|
||||||
manifest/handler change and **minor** on an additive one. At discovery it parses both with
|
version, so a plugin author reads one version off the image they run and writes it down. Both release
|
||||||
`parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies provider/consumer
|
paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift.
|
||||||
semantics in `checkApiVersion`:
|
|
||||||
|
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 |
|
| 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 |
|
| 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 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
|
### Conflict rules
|
||||||
|
|
||||||
@@ -742,7 +748,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
let sql: ReturnType<typeof postgres>;
|
let sql: ReturnType<typeof postgres>;
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "0.1.0",
|
||||||
storage: true,
|
storage: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
onBoot: async (boot) => {
|
onBoot: async (boot) => {
|
||||||
@@ -1372,7 +1378,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
|||||||
| Workflow | Trigger | Does |
|
| 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 |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `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`;
|
`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
|
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
|
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
|
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
|
||||||
repository **description** is maintained by hand from [`README-dockerhub.md`](README-dockerhub.md).
|
|
||||||
|
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
|
**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.
|
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)
|
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
||||||
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
|
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
|
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
|
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
.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
|
## Extending the core
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const groups = on("groups");
|
|||||||
const clients = on("oauth2-clients");
|
const clients = on("oauth2-clients");
|
||||||
|
|
||||||
export default definePlugin({
|
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],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstrea
|
|||||||
const upstream = createUpstream(upstreamUrl);
|
const upstream = createUpstream(upstreamUrl);
|
||||||
|
|
||||||
export default definePlugin({
|
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
|
// 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.
|
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"dev": "node --watch src/server.ts",
|
"dev": "node --watch src/server.ts",
|
||||||
"gen-jwks": "node src/auth/gen-jwks.ts",
|
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
|
||||||
|
|
||||||
|
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
|
||||||
|
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
|
||||||
|
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
|
||||||
|
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the shipped tag and the shipped contract agree", () => {
|
||||||
|
// Guards the pair the release gate checks, so a bump to one fails here before it fails in CI.
|
||||||
|
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
|
||||||
|
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
|
||||||
|
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
|
||||||
|
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
|
||||||
|
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
|
||||||
|
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkTagMatchesContract names what to fix rather than just failing", () => {
|
||||||
|
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
|
||||||
|
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
|
||||||
|
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
|
||||||
|
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// The plugin contract version and the release version are one number (README → Contract
|
||||||
|
// versioning). This is the gate that keeps them one: a tag whose major.minor disagrees with
|
||||||
|
// HOST_API_VERSION would ship a host that misreports itself to every plugin's compatibility check.
|
||||||
|
// Pure and unit tested; the git/tag side lives in the workflows that call the CLI below.
|
||||||
|
|
||||||
|
export type ContractCheck = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
||||||
|
|
||||||
|
export function readHostApiVersion(source: string): string | null {
|
||||||
|
return /HOST_API_VERSION\s*=\s*"([^"]+)"/.exec(source)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
|
||||||
|
// releases with no commit to bump the constant in.
|
||||||
|
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
|
||||||
|
if (hostApiVersion === null) {
|
||||||
|
return { error: "HOST_API_VERSION not found in src/plugin-host/plugin.ts", ok: false };
|
||||||
|
}
|
||||||
|
const t = SEMVER.exec(tag);
|
||||||
|
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
|
||||||
|
const h = SEMVER.exec(hostApiVersion);
|
||||||
|
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
|
||||||
|
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
|
||||||
|
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
|
||||||
|
"then tag.",
|
||||||
|
ok: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI: node auto-release/contract-version.ts <tag> <path/to/plugin.ts> → exits 1 on mismatch.
|
||||||
|
if (process.argv[1]?.endsWith("/contract-version.ts")) {
|
||||||
|
const [, , tag, pluginPath] = process.argv;
|
||||||
|
const { readFileSync } = await import("node:fs");
|
||||||
|
const source = readFileSync(pluginPath ?? "src/plugin-host/plugin.ts", "utf8");
|
||||||
|
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
|
||||||
|
if (!result.ok) {
|
||||||
|
process.stderr.write(`${result.error}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
||||||
|
|
||||||
|
test("renderOverview substitutes every occurrence, not just the first", () => {
|
||||||
|
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
|
||||||
|
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
|
||||||
|
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
|
||||||
|
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the real README-dockerhub.md renders clean and pins no literal image tag", () => {
|
||||||
|
const rendered = renderOverview(readFileSync("README-dockerhub.md", "utf8"), "9.9.9");
|
||||||
|
assert.deepEqual(leftoverPlaceholders(rendered), []);
|
||||||
|
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
|
||||||
|
// A hardcoded version here is what went stale on the live page; the release must own every one.
|
||||||
|
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Publishes README-dockerhub.md as the Docker Hub repository overview. The page is the first thing
|
||||||
|
// an adopter copies, and its image tags were maintained by hand — they pointed at a version that no
|
||||||
|
// longer existed. `{{VERSION}}` is rendered from the tag being promoted, so they cannot go stale.
|
||||||
|
|
||||||
|
const HUB = "https://hub.docker.com/v2";
|
||||||
|
|
||||||
|
export function renderOverview(source: string, version: string): string {
|
||||||
|
return source.replaceAll("{{VERSION}}", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
|
||||||
|
export function leftoverPlaceholders(rendered: string): string[] {
|
||||||
|
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const [, , version] = process.argv;
|
||||||
|
const { readFileSync } = await import("node:fs");
|
||||||
|
const repo = process.env["DOCKERHUB_REPO"];
|
||||||
|
const user = process.env["DOCKERHUB_USER"];
|
||||||
|
const token = process.env["DOCKERHUB_TOKEN"];
|
||||||
|
if (!version || !repo || !user || !token) {
|
||||||
|
process.stderr.write("usage: dockerhub-overview.ts <version>; needs DOCKERHUB_REPO/USER/TOKEN\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = renderOverview(readFileSync("README-dockerhub.md", "utf8"), version);
|
||||||
|
const leftover = leftoverPlaceholders(body);
|
||||||
|
if (leftover.length > 0) {
|
||||||
|
process.stderr.write(`README-dockerhub.md has unrendered placeholders: ${leftover.join(", ")}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = await fetch(`${HUB}/users/login`, {
|
||||||
|
body: JSON.stringify({ password: token, username: user }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!login.ok) {
|
||||||
|
process.stderr.write(`Docker Hub login failed: ${login.status} ${await login.text()}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const { token: jwt } = (await login.json()) as { token?: string };
|
||||||
|
if (!jwt) {
|
||||||
|
process.stderr.write("Docker Hub login returned no token\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${HUB}/repositories/${repo}/`, {
|
||||||
|
body: JSON.stringify({ full_description: body }),
|
||||||
|
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
|
||||||
|
method: "PATCH",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text();
|
||||||
|
process.stderr.write(
|
||||||
|
`Docker Hub overview PATCH failed: ${res.status} ${detail}\n` +
|
||||||
|
(res.status === 403
|
||||||
|
? "403 usually means the token is scoped to the repository's images only — publishing the " +
|
||||||
|
"overview edits repository metadata and needs a token with that permission (README -> CI/CD).\n"
|
||||||
|
: ""),
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
|
||||||
|
process.exit(await main());
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test, type TestContext } from "node:test";
|
import { test, type TestContext } from "node:test";
|
||||||
import { discoverPlugins } from "./discovery.ts";
|
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
|
// 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.
|
// 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 =>
|
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}" }) }] };`;
|
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
||||||
|
|
||||||
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
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, {
|
const dir = scaffold(t, {
|
||||||
"beta/plugin.ts": full("beta"),
|
"beta/plugin.ts": full("beta"),
|
||||||
"alpha/plugin.ts": full("alpha"),
|
"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 });
|
const plugins = await discoverPlugins({ dir });
|
||||||
|
|
||||||
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
|
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(plugins[0]?.nav?.[0]?.label, "alpha");
|
||||||
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
|
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
|
||||||
assert.equal(plugins[0]?.storage, undefined); // storage is opt-in, never assumed
|
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: "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: "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: "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-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: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/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: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*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: "1.0.0", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/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.
|
// 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: "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: "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 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: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*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
|
// 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.
|
// 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 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: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*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: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*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 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 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 },
|
{ 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.
|
// `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 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: "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 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: "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 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) {
|
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
|
// 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.
|
// 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) => {
|
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) => {
|
await assert.rejects(discoverPlugins({ dir }), (err: Error) => {
|
||||||
assert.match(err.message, /gates on "admin"/); // what is wrong
|
assert.match(err.message, /gates on "admin"/); // what is wrong
|
||||||
assert.match(err.message, /re-copy it/); // …and what to do about it
|
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) => {
|
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 });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 1);
|
||||||
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
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) => {
|
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 });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 1);
|
||||||
assert.equal(typeof plugins[0]?.home, "function");
|
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/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/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` +
|
"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 });
|
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) => {
|
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 dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||||
|
|||||||
@@ -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_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the
|
||||||
// host derives both from the plugin's folder name.
|
// host derives both from the plugin's folder name.
|
||||||
const scheduling: PluginManifest = definePlugin({
|
const scheduling: PluginManifest = definePlugin({
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "0.1.0",
|
||||||
hooks: { onBoot: () => {} },
|
hooks: { onBoot: () => {} },
|
||||||
nav: [{
|
nav: [{
|
||||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
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", () => {
|
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.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.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
|
assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import type { RequestContext } from "../http/context.ts";
|
|||||||
import type { NavNode } from "../ui/nav.ts";
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
import type { StorageCredentials } from "./storage.ts";
|
import type { StorageCredentials } from "./storage.ts";
|
||||||
|
|
||||||
// Bump major on a breaking manifest/handler change, minor on an additive one.
|
// The Plainpages release this contract ships in — one version, not a second one to track. Its
|
||||||
export const HOST_API_VERSION = "1.0.0";
|
// 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";
|
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -24,5 +24,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": 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
@@ -14,7 +14,7 @@
|
|||||||
<p>${t("dashboard.starter.intro")}</p>
|
<p>${t("dashboard.starter.intro")}</p>
|
||||||
<p>${t("dashboard.starter.replace")}</p>
|
<p>${t("dashboard.starter.replace")}</p>
|
||||||
<pre class="code-block"><code>export default definePlugin({
|
<pre class="code-block"><code>export default definePlugin({
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "0.1.0",
|
||||||
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
||||||
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
||||||
});</code></pre>
|
});</code></pre>
|
||||||
|
|||||||
Reference in New Issue
Block a user