Release this as v0.2.0, and give the Hub overview its own job, token and template
CI / full-gate (push) Successful in 3m8s

This commit is contained in:
2026-08-20 23:32:10 +02:00
parent 2148822dad
commit 99e77ebabf
20 changed files with 155 additions and 82 deletions
+33 -14
View File
@@ -2,9 +2,17 @@ name: Release
on: on:
push: push:
tags: ['v[0-9]+.[0-9]+.[0-9]+'] tags: ['v[0-9]+.[0-9]+.[0-9]+']
# The overview has its own door: a stale page is exactly the state you cannot fix by cutting a
# release, so republishing it must not require one.
workflow_dispatch:
inputs:
overview_version:
description: 'Version the overview should tell adopters to pull (e.g. 0.2.0)'
required: true
jobs: jobs:
retag-image: retag-image:
if: github.event_name == 'push'
runs-on: docker-host runs-on: docker-host
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
@@ -34,7 +42,7 @@ jobs:
done done
- name: Sync the release tags to Docker Hub - name: Sync the release tags to Docker Hub
env: env:
DOCKERHUB_REPO: docker.io/${{ github.repository }} DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
@@ -46,22 +54,33 @@ jobs:
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; } || { 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 printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG" docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
docker push "$DOCKERHUB_REPO:$TAG" docker push "$DOCKERHUB_IMAGE:$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: |
docker logout gitea.larvit.se docker logout gitea.larvit.se
docker logout docker.io docker logout docker.io
# Its own job, not a step: the images are already pushed and irreversible by this point, so a Hub
# API outage or an under-scoped token must not report the release itself as failed.
publish-overview:
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
needs: [retag-image]
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- name: Publish the Docker Hub overview
env:
DOCKERHUB_OVERVIEW_TOKEN: ${{ secrets.DOCKERHUB_OVERVIEW_TOKEN }}
DOCKERHUB_REPO: ${{ github.repository }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }}
INPUT_VERSION: ${{ inputs.overview_version }}
run: |
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_OVERVIEW_TOKEN -e DOCKERHUB_REPO -e DOCKERHUB_USER \
node:24.19.0-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+5 -6
View File
@@ -29,7 +29,7 @@ jobs:
# (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:` # (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 # 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; # 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). # already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
auto-release: auto-release:
runs-on: docker-host runs-on: docker-host
@@ -59,12 +59,11 @@ jobs:
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
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 release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made. # 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 git show origin/main:src/plugin-host/plugin.ts \
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ | docker run -i --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" plugin-at-main.ts node release-tooling/contract-version.ts "$NEXT" -
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"
+3 -2
View File
@@ -291,8 +291,9 @@ Revisit only if the stated reason stops holding.
workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: 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 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 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 *text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
load-bearing. `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
guards it, so renaming it back would let a broken `{{VERSION}}` merge with its own guard skipped.
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so - **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 `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 can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
+43 -18
View File
@@ -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: "0.1.0", apiVersion: "0.2.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: "0.1.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user. // 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: "0.1.0", apiVersion: "0.2.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
}); });
@@ -612,7 +612,10 @@ provider/consumer 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. While Plainpages is `0.x` every release shares major `0`, so a plugin the caret-style compatibility. Note what a **minor** means now that one digit carries the whole
release: either the plugin contract changed, or a dependency moved far enough to warrant one. A
`warn` therefore says "built against an older release", not "new plugin features exist" — check the
Upgrading section for that release before assuming there is anything to adopt. While Plainpages is `0.x` every release shares major `0`, so a plugin
built against `0.1.0` still loads on a `0.9.0` host with a `warn`; reaching `1.0.0` refuses everything built against `0.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. built against `0.x`, which is the point of that milestone.
@@ -748,7 +751,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>; let sql: ReturnType<typeof postgres>;
export default definePlugin({ export default definePlugin({
apiVersion: "0.1.0", apiVersion: "0.2.0",
storage: true, storage: true,
hooks: { hooks: {
onBoot: async (boot) => { onBoot: async (boot) => {
@@ -1378,7 +1381,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 | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`, sync those tags to Docker Hub and publish its overview | | `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`, sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | | `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 |
@@ -1408,13 +1411,17 @@ nothing is rebuilt, so the released image is byte-identical to the gated one. It
hash image exists — release tags must point at a commit that went through the gate. The same four 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. tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
Two things guard the tag before anything is published. The The [contract check](#contract-versioning) guards the tag before anything is published, refusing one
[contract check](#contract-versioning) refuses a tag whose `major.minor` disagrees with whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
`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 **The Docker Hub overview** is published by a separate `publish-overview` job from
release — so the image tags it tells adopters to pull cannot go stale. That needs `DOCKERHUB_TOKEN` [`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
to carry permission to edit repository metadata, not just push images; the step names this if it `{{VERSION}}` rendered to the release, so the image tags it tells adopters to pull cannot go stale.
403s. It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage must not report a good release as failed; and the page has its own door — run the workflow
manually with an `overview_version` input to republish it without cutting a release. It uses
`DOCKERHUB_OVERVIEW_TOKEN`, separate from the image-push token because editing repository metadata is
a different permission and widening the push credential to cover it would widen what a leak costs.
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is **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.
@@ -1436,8 +1443,11 @@ exact. Each PR runs the normal gate on its `renovate/*` branch and automerges on
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the root `Release-Bump: <updateType>` 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 `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 [`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`. `updateType` rates the *dependency's* own jump, version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
so the trailer is an allowlist in [`renovate.json`](renovate.json): a devDependency, E2E or CI-only 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 bump carries none and rides the next patch release instead of escalating it. It is **tag-only**: the tag hands off to
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by `release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
@@ -1449,6 +1459,7 @@ the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never tou
| --- | --- | | --- | --- |
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. | | `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 token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT can push to every repo under it). |
| `DOCKERHUB_OVERVIEW_TOKEN` (secret) | A Docker Hub PAT that may **edit repository metadata**, used only to publish the overview. Separate from `DOCKERHUB_TOKEN` so the image-push credential stays narrow; without it the `publish-overview` job fails and the released images are unaffected. |
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. | | `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_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. | | `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
@@ -1502,6 +1513,21 @@ box. The web app waits for Kratos + Keto healthy *and* the bootstrap to finish b
## Upgrading ## Upgrading
### 0.1.x → 0.2.0: `apiVersion` now names the Plainpages release
`0.1.0` shipped a host reporting contract version `1.0.0` — a second number that tracked the plugin
contract separately from the release. There is only one number now, and it is the release: a host on
`0.2.0` reports `0.2.0`. Every plugin must say so, or discovery refuses it by version and **aborts
boot**:
```ts
// plugins/<your-plugin>/plugin.ts
apiVersion: "0.2.0", // was "1.0.0"
```
Re-copy anything taken from `examples/` (below) to pick this up. Nothing else in the contract
changed, so a plugin that boots after the edit needs no further work.
**Re-copy your drop-in plugins.** Anything under `plugins/` is *your* copy — the host never updates **Re-copy your drop-in plugins.** Anything under `plugins/` is *your* copy — the host never updates
it. A plugin copied from `examples/` is still the old one after you pull, and the host may have it. A plugin copied from `examples/` is still the old one after you pull, and the host may have
tightened a manifest rule since. Discovery fails loud at boot rather than running a plugin it can't tightened a manifest rule since. Discovery fails loud at boot rather than running a plugin it can't
@@ -1651,12 +1677,11 @@ examples/ Copy-in reference mirroring the mount dirs: plugins/schedul
config/menu.ts, and shifts-upstream/ (the dev mock backend) 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/ Everything the release runs: next-version (the bump math), contract-version
release-tooling/ Run by the release itself: the HOST_API_VERSION↔tag gate, the Docker Hub publisher (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) 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 overview; release.yml renders {{VERSION}} and publishes it
``` ```
## Extending the core ## Extending the core
+1 -1
View File
@@ -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: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
+1 -1
View File
@@ -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: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a // 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
View File
@@ -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\" \"release-tooling/**/*.test.ts\" \"auto-release/**/*.test.ts\"" "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
+37 -13
View File
@@ -9,27 +9,51 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null); assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
}); });
test("the shipped tag and the shipped contract agree", () => { test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Guards the pair the release gate checks, so a bump to one fails here before it fails in CI. // Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0"); // makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
// is the only thing keeping the copies honest.
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
const [major, minor] = host.split(".");
for (const file of [
"README.md",
"examples/plugins/admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl",
"views/index.ejs",
]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
for (const sample of found) {
const [sMajor, sMinor] = (sample ?? "").split(".");
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
}
}
}); });
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => { test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true); assert.equal(checkTagMatchesContract("v0.2.0", "0.2.0").ok, true);
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches assert.equal(checkTagMatchesContract("v0.2.7", "0.2.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("0.2.0", "0.2.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("v0.3.0", "0.2.0").ok, false); // plugin-visible, needs a bump
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false); assert.equal(checkTagMatchesContract("v0.1.0", "0.2.0").ok, false); // the previously released line
assert.equal(checkTagMatchesContract("v1.0.0", "0.2.0").ok, false);
}); });
test("checkTagMatchesContract names what to fix rather than just failing", () => { test("checkTagMatchesContract names what to fix rather than just failing", () => {
const res = checkTagMatchesContract("v0.2.0", "0.1.0"); const res = checkTagMatchesContract("v0.3.0", "0.2.0");
assert.equal(res.ok, false); assert.equal(res.ok, false);
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/); assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.3\.0/);
}); });
test("checkTagMatchesContract rejects junk on either side without throwing", () => { test("checkTagMatchesContract rejects junk on either side without throwing", () => {
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found assert.equal(checkTagMatchesContract("v0.2.0", null).ok, false); // constant not found
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false); assert.equal(checkTagMatchesContract("nope", "0.2.0").ok, false);
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false); assert.equal(checkTagMatchesContract("v0.2.0", "1.0").ok, false);
}); });
+7 -5
View File
@@ -15,7 +15,7 @@ export function readHostApiVersion(source: string): string | null {
// releases with no commit to bump the constant in. // releases with no commit to bump the constant in.
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck { export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
if (hostApiVersion === null) { if (hostApiVersion === null) {
return { error: "HOST_API_VERSION not found in src/plugin-host/plugin.ts", ok: false }; return { error: "HOST_API_VERSION not found", ok: false };
} }
const t = SEMVER.exec(tag); const t = SEMVER.exec(tag);
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false }; if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
@@ -31,14 +31,16 @@ export function checkTagMatchesContract(tag: string, hostApiVersion: string | nu
}; };
} }
// CLI: node auto-release/contract-version.ts <tag> <path/to/plugin.ts> → exits 1 on mismatch. // CLI: node release-tooling/contract-version.ts <tag> <path/to/plugin.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")) { if (process.argv[1]?.endsWith("/contract-version.ts")) {
const [, , tag, pluginPath] = process.argv; const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
const { readFileSync } = await import("node:fs"); const { readFileSync } = await import("node:fs");
const source = readFileSync(pluginPath ?? "src/plugin-host/plugin.ts", "utf8"); const source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source)); const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
if (!result.ok) { if (!result.ok) {
process.stderr.write(`${result.error}\n`); process.stderr.write(`${pluginPath}: ${result.error}\n`);
process.exit(1); process.exit(1);
} }
process.stdout.write(`${tag} matches HOST_API_VERSION\n`); process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
@@ -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: "0.1.0", apiVersion: "0.2.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>" }) },
+2 -2
View File
@@ -13,8 +13,8 @@ test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes cle
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []); assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
}); });
test("the real README-dockerhub.md renders clean and pins no literal image tag", () => { test("the real template renders clean and pins no literal image tag", () => {
const rendered = renderOverview(readFileSync("README-dockerhub.md", "utf8"), "9.9.9"); const rendered = renderOverview(readFileSync("release-tooling/dockerhub-overview.md.tmpl", "utf8"), "9.9.9");
assert.deepEqual(leftoverPlaceholders(rendered), []); assert.deepEqual(leftoverPlaceholders(rendered), []);
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples 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. // A hardcoded version here is what went stale on the live page; the release must own every one.
+11 -9
View File
@@ -1,6 +1,6 @@
// Publishes README-dockerhub.md as the Docker Hub repository overview. The page is the first thing // Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl. The page is the
// an adopter copies, and its image tags were maintained by hand — they pointed at a version that no // first thing an adopter copies, and its image tags were maintained by hand — they pointed at a
// longer existed. `{{VERSION}}` is rendered from the tag being promoted, so they cannot go stale. // version that no longer existed. `{{VERSION}}` is rendered from the release, so they cannot.
const HUB = "https://hub.docker.com/v2"; const HUB = "https://hub.docker.com/v2";
@@ -18,16 +18,18 @@ async function main(): Promise<number> {
const { readFileSync } = await import("node:fs"); const { readFileSync } = await import("node:fs");
const repo = process.env["DOCKERHUB_REPO"]; const repo = process.env["DOCKERHUB_REPO"];
const user = process.env["DOCKERHUB_USER"]; const user = process.env["DOCKERHUB_USER"];
const token = process.env["DOCKERHUB_TOKEN"]; const token = process.env["DOCKERHUB_OVERVIEW_TOKEN"];
if (!version || !repo || !user || !token) { if (!version || !repo || !user || !token) {
process.stderr.write("usage: dockerhub-overview.ts <version>; needs DOCKERHUB_REPO/USER/TOKEN\n"); process.stderr.write(
"usage: dockerhub-overview.ts <version>; needs DOCKERHUB_REPO, DOCKERHUB_USER, DOCKERHUB_OVERVIEW_TOKEN\n",
);
return 1; return 1;
} }
const body = renderOverview(readFileSync("README-dockerhub.md", "utf8"), version); const body = renderOverview(readFileSync("release-tooling/dockerhub-overview.md.tmpl", "utf8"), version);
const leftover = leftoverPlaceholders(body); const leftover = leftoverPlaceholders(body);
if (leftover.length > 0) { if (leftover.length > 0) {
process.stderr.write(`README-dockerhub.md has unrendered placeholders: ${leftover.join(", ")}\n`); process.stderr.write(`release-tooling/dockerhub-overview.md.tmpl has unrendered placeholders: ${leftover.join(", ")}\n`);
return 1; return 1;
} }
@@ -56,8 +58,8 @@ async function main(): Promise<number> {
process.stderr.write( process.stderr.write(
`Docker Hub overview PATCH failed: ${res.status} ${detail}\n` + `Docker Hub overview PATCH failed: ${res.status} ${detail}\n` +
(res.status === 403 (res.status === 403
? "403 usually means the token is scoped to the repository's images only — publishing the " + ? "403 means DOCKERHUB_OVERVIEW_TOKEN cannot edit repository metadata — that is a separate " +
"overview edits repository metadata and needs a token with that permission (README -> CI/CD).\n" "permission from pushing images, which is why it is its own secret (README -> CI/CD).\n"
: ""), : ""),
); );
return 1; return 1;
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
return `v${major}.${minor}.${patch + 1}`; return `v${major}.${minor}.${patch + 1}`;
} }
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag. // CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag.
if (process.argv[1]?.endsWith("/next-version.ts")) { if (process.argv[1]?.endsWith("/next-version.ts")) {
const [, , latestTag, ...updateTypes] = process.argv; const [, , latestTag, ...updateTypes] = process.argv;
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes))); process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
+2 -2
View File
@@ -53,8 +53,8 @@
}, },
{ {
"customType": "regex", "customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)", "description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"], "managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
"depNameTemplate": "node", "depNameTemplate": "node",
"datasourceTemplate": "docker" "datasourceTemplate": "docker"
+1 -1
View File
@@ -20,7 +20,7 @@ import { AUTH_FLOWS } from "../auth/flow-view.ts";
// HOST_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the // HOST_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: "0.1.0", apiVersion: "0.2.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" }],
+2 -2
View File
@@ -11,7 +11,7 @@ import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in — one version, not a second one to track. Its // The Plainpages release this contract ships in — one version, not a second one to track. Its
// major.minor must equal the release tag's; `release.yml` refuses to promote a tag that disagrees. // 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. // The patch digit may lag, since checkApiVersion ignores patch and auto-release cuts patches itself.
export const HOST_API_VERSION = "0.1.0"; export const HOST_API_VERSION = "0.2.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
@@ -160,7 +160,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` }; return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
} }
if (plugin.minor < host.minor) { if (plugin.minor < host.minor) {
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion}newer features available` }; return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion}built against an older release` };
} }
return { level: "ok", message: `apiVersion ${pluginVersion}` }; return { level: "ok", message: `apiVersion ${pluginVersion}` };
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import { createApp } from "./http/app.ts";
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
import { createDenylist } from "./auth/denylist.ts"; import { createDenylist } from "./auth/denylist.ts";
import { discoverPlugins } from "./plugin-host/discovery.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 { withTimeout } from "./auth/fetch-timeout.ts";
import { runBootHooks } from "./plugin-host/hooks.ts"; import { runBootHooks } from "./plugin-host/hooks.ts";
import { createHydraAdmin } from "./auth/hydra-admin.ts"; import { createHydraAdmin } from "./auth/hydra-admin.ts";
@@ -91,7 +92,7 @@ const server = createApp({
plugins, plugins,
secureCookies: config.secureCookies, secureCookies: config.secureCookies,
}).listen(config.port, () => { }).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 // Drain in-flight requests on container stop instead of cutting them mid-response, then flush any
+1 -1
View File
@@ -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", "release-tooling", "src"] "include": ["config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "release-tooling", "src"]
} }
+1 -1
View File
@@ -14,7 +14,7 @@
<p>${t("dashboard.starter.intro")}</p> <p>${t("dashboard.starter.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: "0.1.0", apiVersion: "0.2.0",
// view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell // view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell
dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }), dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }),
});</code></pre> });</code></pre>