diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index 05bbfd8..975d9c5 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -2,12 +2,27 @@ name: Release
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+']
+ workflow_dispatch:
+ inputs:
+ overview_version:
+ description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
+ required: true
jobs:
retag-image:
+ if: github.event_name == 'push'
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: |
+ 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
env:
GIT_TAG: ${{ github.ref_name }}
@@ -15,34 +30,77 @@ 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
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
- 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 }}
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" ] \
|| { 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"
+ 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
- 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
+
+ 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
+ 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: refs/tags/v${{ inputs.overview_version }}
+ - 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 }}
+ INPUT_VERSION: ${{ inputs.overview_version }}
+ 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
+ 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 "$VERSION"
diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml
index 9ee960d..3b02069 100644
--- a/.gitea/workflows/renovate.yml
+++ b/.gitea/workflows/renovate.yml
@@ -26,9 +26,10 @@ 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
+ # 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
@@ -54,8 +55,15 @@ 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)
+ 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 \
+ | 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 0410c47..ea2c00a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -287,12 +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
- *text* guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes
- load-bearing.
+- **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. `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
@@ -347,14 +352,18 @@ 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.
-- **`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
+- **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`
+ 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.md b/README.md
index 51f9d64..8ecec03 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,22 +593,29 @@ 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. 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
+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 |
| --- | --- | --- |
| same major, same minor (patch ignored) | `ok` | load |
-| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist |
+| **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 |
| 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.
+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.
+
### Conflict rules
@@ -742,7 +749,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 +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 | 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, 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 |
@@ -1397,11 +1404,27 @@ 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. 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.
+
+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 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: a non-semver value, or one whose `major.minor`
+disagrees with the tree being published, is refused. It uses
+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.
@@ -1419,13 +1442,17 @@ 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
-`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,
-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
+**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 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: 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.
@@ -1434,7 +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_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. |
@@ -1637,9 +1664,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
+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 description, pasted over by hand when it changes
```
## 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..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\" \"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
new file mode 100644
index 0000000..8f3d780
--- /dev/null
+++ b/release-tooling/contract-version.test.ts
@@ -0,0 +1,58 @@
+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("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.1.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);
+});
+
+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..88e9b17
--- /dev/null
+++ b/release-tooling/contract-version.ts
@@ -0,0 +1,49 @@
+import { readFileSync } from "node:fs";
+
+export type ContractCheck = { ok: true } | { ok: false; error: string };
+
+const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
+
+export function readHostApiVersion(source: string): string | 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
+// 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", 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 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 = "src/plugin-host/plugin.ts"] = process.argv;
+ let source = "";
+ try {
+ source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
+ } catch (err) {
+ process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
+ process.exitCode = 1;
+ }
+ const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
+ if (!result.ok) {
+ 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 94%
rename from README-dockerhub.md
rename to release-tooling/dockerhub-overview.md.tmpl
index cff564d..c38c5e7 100644
--- a/README-dockerhub.md
+++ 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
@@ -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:
@@ -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.31.0
ports:
- "8025:8025"
restart: unless-stopped
@@ -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/release-tooling/dockerhub-overview.test.ts b/release-tooling/dockerhub-overview.test.ts
new file mode 100644
index 0000000..f0153b0
--- /dev/null
+++ b/release-tooling/dockerhub-overview.test.ts
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+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");
+ 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 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
+ 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.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");
+ 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
new file mode 100644
index 0000000..f637c99
--- /dev/null
+++ b/release-tooling/dockerhub-overview.ts
@@ -0,0 +1,104 @@
+// 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);
+}
+
+// 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) ?? [])];
+}
+
+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 repo = process.env["DOCKERHUB_REPO"];
+ const user = process.env["DOCKERHUB_USER"];
+ const token = process.env["DOCKERHUB_TOKEN"];
+ if (!version || !repo || !user || !token) {
+ return fail(
+ "usage: dockerhub-overview.ts ; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
+ "DOCKERHUB_TOKEN (README -> CI/CD)",
+ );
+ }
+ // 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 templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
+ 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(", ")}`);
+
+ const login = await post(`${HUB}/users/login`, {
+ body: JSON.stringify({ password: token, username: user }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ 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 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) {
+ return fail(
+ `Docker Hub overview PATCH failed: ${res.status} ${res.text}` +
+ (res.status === 403
+ ? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
+ "read/write/delete, which pushing images alone does not (README -> CI/CD)."
+ : ""),
+ );
+ }
+ process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
+ return 0;
+}
+
+if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
+ process.exitCode = await main();
+}
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..86d345a 100644
--- a/renovate.json
+++ b/renovate.json
@@ -6,7 +6,7 @@
"automerge": true,
"packageRules": [
{
- "description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD) because updateType rates the dependency's own jump, not its effect here — unscoped, the bot's self-update bumped the product",
+ "description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
"matchDepTypes": ["dependencies"],
"matchFileNames": ["package.json"],
"matchManagers": ["npm"],
@@ -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"],
@@ -53,8 +59,15 @@
},
{
"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": "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)",
+ "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/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..d4b123e 100644
--- a/src/plugin-host/plugin.test.ts
+++ b/src/plugin-host/plugin.test.ts
@@ -80,12 +80,15 @@ 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
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.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 18151af..2fed523 100644
--- a/src/plugin-host/plugin.ts
+++ b/src/plugin-host/plugin.ts
@@ -8,8 +8,8 @@ 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 — see README → Contract versioning.
+export const HOST_API_VERSION = "0.1.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
@@ -158,7 +158,11 @@ 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` };
+ // 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}` };
}
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 3eec1c1..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", "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 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: { /* … */ } }),
});