From 716e0f50a71bcf69a1d4fa94d1b258accc464e61 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 22 Aug 2026 12:27:43 +0200 Subject: [PATCH] Give each version mismatch its own remedy, and stop publishing a moving bare-major tag --- .gitea/workflows/release.yml | 15 +++++++++++--- AGENTS.md | 10 +++++----- README.md | 23 ++++++++++------------ release-tooling/contract-version.ts | 7 ++----- release-tooling/dockerhub-overview.md.tmpl | 2 +- release-tooling/dockerhub-overview.ts | 2 +- src/plugin-host/plugin.test.ts | 2 +- src/plugin-host/plugin.ts | 14 +++++-------- 8 files changed, 37 insertions(+), 38 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ddb4477..471bcff 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -36,7 +36,11 @@ jobs: printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin docker pull "$REPO:$COMMIT" \ || { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; } - for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do + # No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move + # across one and abort boot for everything tracking it. `:0.1` only moves across patches. + TAGS="$VERSION ${VERSION%.*} latest" + if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi + for TAG in $TAGS; do docker tag "$REPO:$COMMIT" "$REPO:$TAG" docker push "$REPO:$TAG" done @@ -54,7 +58,9 @@ jobs: [ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \ || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; } printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin - for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do + TAGS="$VERSION ${VERSION%.*} latest" + if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi + for TAG in $TAGS; do docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG" docker push "$DOCKERHUB_IMAGE:$TAG" done @@ -72,11 +78,14 @@ jobs: needs: [retag-image] runs-on: docker-host steps: + - uses: actions/checkout@v7.0.1 + if: github.event_name == 'push' # Publish the named release's own tree, so the page never pairs one Plainpages tag with another # release's sidecar pins. A version that was never released fails here. - uses: actions/checkout@v7.0.1 + if: github.event_name == 'workflow_dispatch' with: - ref: ${{ inputs.overview_version && format('refs/tags/v{0}', inputs.overview_version) || github.ref }} + ref: refs/tags/v${{ inputs.overview_version }} - name: Publish the Docker Hub overview env: DOCKERHUB_OVERVIEW_TOKEN: ${{ secrets.DOCKERHUB_OVERVIEW_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 875a788..ea2c00a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -352,11 +352,11 @@ one-time setup. A file-map or table row gets a clause, not a paragraph. - Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images by tag. -- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist* — only - the root `package.json`'s runtime deps, the `Dockerfile` base and `compose.yml`'s services carry the - trailer, so a dependency added anywhere else never escalates the release version and nothing fails to - say so. A new manifest, compose file, custom manager or dep type is a decision: can it reach a - running Plainpages? If yes it needs a rule; if no, record nothing and let it ride the next patch. +- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its + rules name exactly what carries the trailer, so a dependency outside them never escalates the + release version and nothing fails to say so. A new manifest, compose file, custom manager or dep + type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing + and let it ride the next patch. - **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch digit may lag on purpose: `checkApiVersion` diff --git a/README.md b/README.md index fef3f21..e2c8540 100644 --- a/README.md +++ b/README.md @@ -606,8 +606,8 @@ provider/consumer semantics in `checkApiVersion`: | Plugin `apiVersion` vs host | Result | Host action | | --- | --- | --- | | same major, same minor (patch ignored) | `ok` | load | +| **major `0`**, plugin minor **<** host minor | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot | | same major, plugin minor **<** host minor | `warn` | load, log — built against an older release; check that release's notes | -| **major `0`**, any minor mismatch | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host | | different major | `refuse` | **abort boot** — incompatible contract | | missing / not a valid semver | `refuse` | **abort boot** — must be declared | @@ -616,11 +616,6 @@ The plugin pins one exact version (no ranges, per the project's pinning rules); the compatibility. One digit carries the whole release, so a **minor** means either the plugin contract changed or a dependency moved far enough to warrant one. -While Plainpages is `0.x` the major stays `0`, which leaves the minor as the only slot a breaking -change can use — so a minor mismatch is refused rather than warned: a plugin declaring `0.1.0` will -not boot on a `0.2.0` host until it is rebuilt against it. Once `1.0.0` lands the minor becomes -additive and the `warn` row applies, saying "built against an older release" rather than promising -new features. ### Conflict rules @@ -1384,7 +1379,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs | Workflow | Trigger | Does | | --- | --- | --- | | `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image | -| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`, sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger | +| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger | | `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | | `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head | | `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged | @@ -1409,8 +1404,10 @@ pattern-based org cleanup rule for this package — its age/count heuristics can release tags and would delete images the workflow protects. **Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs -`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `1`, `latest`; -nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no +`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and — +once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the +gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract +break and a moving `:0` would carry one. It fails loud if no hash image exists — release tags must point at a commit that went through the gate. The same four tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. @@ -1448,15 +1445,15 @@ exact. Each PR runs the normal gate on its `renovate/*` branch and automerges on `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and **skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a -`Release-Bump: ` trailer onto the updates that reach a running Plainpages — the root -`package.json`'s runtime dependencies, the image base, and `compose.yml`'s services — and +`Release-Bump: ` trailer onto the updates that reach a running Plainpages — the rules in +[`renovate.json`](renovate.json) name them — and [`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release version, an update big enough to reach a **minor** stops the job rather than tagging: bump `HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since `nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump, -so the trailer is an allowlist in [`renovate.json`](renovate.json): a devDependency, E2E or CI-only -bump carries none and rides the next patch release instead of escalating it. It is **tag-only**: the tag hands off to +so the trailer is an allowlist: an update outside those rules carries none and rides the next patch +release instead of escalating it. It is **tag-only**: the tag hands off to `release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here. diff --git a/release-tooling/contract-version.ts b/release-tooling/contract-version.ts index 5248ce7..88e9b17 100644 --- a/release-tooling/contract-version.ts +++ b/release-tooling/contract-version.ts @@ -1,6 +1,4 @@ -// The gate that keeps the contract version and the release version one number (README → Contract -// versioning): a tag whose major.minor disagrees with HOST_API_VERSION would ship a host that -// misreports itself to every plugin's compatibility check. +import { readFileSync } from "node:fs"; export type ContractCheck = { ok: true } | { ok: false; error: string }; @@ -35,13 +33,12 @@ export function checkTagMatchesContract(tag: string, hostApiVersion: string | nu // (`git show origin/main:… | …`) needs no scratch file in the workspace. if (process.argv[1]?.endsWith("/contract-version.ts")) { const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv; - const { readFileSync } = await import("node:fs"); let source = ""; try { source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8"); } catch (err) { process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); + process.exitCode = 1; } const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source)); if (!result.ok) { diff --git a/release-tooling/dockerhub-overview.md.tmpl b/release-tooling/dockerhub-overview.md.tmpl index 8813f06..aa2da84 100644 --- a/release-tooling/dockerhub-overview.md.tmpl +++ b/release-tooling/dockerhub-overview.md.tmpl @@ -10,7 +10,7 @@ one; the host itself is stateless, and there is no build step. ## Tags -`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build. +`X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build. Pin the exact `X.Y.Z` you deploy. ## Quick start diff --git a/release-tooling/dockerhub-overview.ts b/release-tooling/dockerhub-overview.ts index a7e06f0..4dca3fe 100644 --- a/release-tooling/dockerhub-overview.ts +++ b/release-tooling/dockerhub-overview.ts @@ -100,5 +100,5 @@ async function main(): Promise { } if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) { - process.exit(await main()); + process.exitCode = await main(); } diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index ceae298..d4b123e 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -88,7 +88,7 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older) assert.equal(checkApiVersion("0.1.0", "0.1.9").level, "ok"); // pre-1.0 patch is still ignored assert.equal(checkApiVersion("0.1.0", "0.2.0").level, "refuse"); // pre-1.0 the minor IS the breaking slot - assert.equal(checkApiVersion("0.2.0", "0.1.0").level, "refuse"); + assert.match(checkApiVersion("0.2.0", "0.1.0").message, /upgrade the host/); // ahead of the host, even pre-1.0 for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) { assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`); } diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 9ff9f5a..2fed523 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -8,9 +8,7 @@ import type { RequestContext } from "../http/context.ts"; import type { NavNode } from "../ui/nav.ts"; import type { StorageCredentials } from "./storage.ts"; -// The Plainpages release this contract ships in. Its major.minor must equal the release tag's, and -// both release paths refuse a tag that disagrees. The patch digit may lag: checkApiVersion ignores -// patch, and auto-release cuts patch releases with no commit to bump this in. +// The Plainpages release this contract ships in — see README → Contract versioning. export const HOST_API_VERSION = "0.1.0"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; @@ -156,16 +154,14 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO if (plugin.major !== host.major) { return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — incompatible major` }; } - // Pre-1.0 the major is pinned at 0 until the 1.0.0 milestone, so a breaking change can only land - // as a minor (release-tooling/next-version.ts shifts every level down). Treating that as additive - // would let a stale plugin boot and fail at runtime instead of at discovery. - if (host.major === 0 && plugin.minor !== host.minor) { - return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — pre-1.0 a minor is a contract break, rebuild against ${hostVersion}` }; - } if (plugin.minor > host.minor) { return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` }; } if (plugin.minor < host.minor) { + // Pre-1.0 the major is pinned at 0, so a minor is the only slot a breaking change can use. + if (host.major === 0) { + return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — pre-1.0 a minor is a contract break, rebuild against ${hostVersion}` }; + } return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — built against an older release` }; } return { level: "ok", message: `apiVersion ${pluginVersion}` };