Give each version mismatch its own remedy, and stop publishing a moving bare-major tag
CI / full-gate (push) Successful in 2m51s

This commit is contained in:
2026-08-22 12:27:43 +02:00
parent 2d62553a10
commit 716e0f50a7
8 changed files with 37 additions and 38 deletions
+12 -3
View File
@@ -36,7 +36,11 @@ jobs:
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
docker pull "$REPO:$COMMIT" \ docker pull "$REPO:$COMMIT" \
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; } || { 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 tag "$REPO:$COMMIT" "$REPO:$TAG"
docker push "$REPO:$TAG" docker push "$REPO:$TAG"
done done
@@ -54,7 +58,9 @@ jobs:
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \ [ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|| { 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 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 tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
docker push "$DOCKERHUB_IMAGE:$TAG" docker push "$DOCKERHUB_IMAGE:$TAG"
done done
@@ -72,11 +78,14 @@ jobs:
needs: [retag-image] needs: [retag-image]
runs-on: docker-host runs-on: docker-host
steps: 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 # 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. # release's sidecar pins. A version that was never released fails here.
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
if: github.event_name == 'workflow_dispatch'
with: 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 - name: Publish the Docker Hub overview
env: env:
DOCKERHUB_OVERVIEW_TOKEN: ${{ secrets.DOCKERHUB_OVERVIEW_TOKEN }} DOCKERHUB_OVERVIEW_TOKEN: ${{ secrets.DOCKERHUB_OVERVIEW_TOKEN }}
+5 -5
View File
@@ -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 - 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 ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
by tag. by tag.
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist* — only - **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
the root `package.json`'s runtime deps, the `Dockerfile` base and `compose.yml`'s services carry the rules name exactly what carries the trailer, so a dependency outside them never escalates the
trailer, so a dependency added anywhere else never escalates the release version and nothing fails to release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
say so. A new manifest, compose file, custom manager or dep type is a decision: can it reach a type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
running Plainpages? If yes it needs a rule; if no, record nothing and let it ride the next patch. and let it ride the next patch.
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and - **`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 both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
digit may lag on purpose: `checkApiVersion` digit may lag on purpose: `checkApiVersion`
+10 -13
View File
@@ -606,8 +606,8 @@ provider/consumer semantics in `checkApiVersion`:
| Plugin `apiVersion` vs host | Result | Host action | | Plugin `apiVersion` vs host | Result | Host action |
| --- | --- | --- | | --- | --- | --- |
| same major, same minor (patch ignored) | `ok` | load | | 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 | | 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 | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
| different major | `refuse` | **abort boot** — incompatible contract | | different major | `refuse` | **abort boot** — incompatible contract |
| missing / not a valid semver | `refuse` | **abort boot** — must be declared | | 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 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. 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 ### Conflict rules
@@ -1384,7 +1379,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, 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) | | `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 |
@@ -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. 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 **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`; `release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no 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 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.
@@ -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 `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 **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 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 rules in
`package.json`'s runtime dependencies, the image base, and `compose.yml`'s services — and [`renovate.json`](renovate.json) name them — and
[`release-tooling/next-version.ts`](release-tooling/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`. Because the contract version *is* the release 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 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 `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, `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: an update outside those rules carries none and rides the next patch
bump carries none and rides the next patch release instead of escalating it. It is **tag-only**: the tag hands off to 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
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here. the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
+2 -5
View File
@@ -1,6 +1,4 @@
// The gate that keeps the contract version and the release version one number (README → Contract import { readFileSync } from "node:fs";
// versioning): a tag whose major.minor disagrees with HOST_API_VERSION would ship a host that
// misreports itself to every plugin's compatibility check.
export type ContractCheck = { ok: true } | { ok: false; error: string }; 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. // (`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 = "src/plugin-host/plugin.ts"] = process.argv; const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
const { readFileSync } = await import("node:fs");
let source = ""; let source = "";
try { try {
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8"); source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
} catch (err) { } catch (err) {
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`); process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1); process.exitCode = 1;
} }
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source)); const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
if (!result.ok) { if (!result.ok) {
+1 -1
View File
@@ -10,7 +10,7 @@ one; the host itself is stateless, and there is no build step.
## Tags ## 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. Pin the exact `X.Y.Z` you deploy.
## Quick start ## Quick start
+1 -1
View File
@@ -100,5 +100,5 @@ async function main(): Promise<number> {
} }
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) { if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
process.exit(await main()); process.exitCode = await main();
} }
+1 -1
View File
@@ -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("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.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.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]) { 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`); assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
} }
+5 -9
View File
@@ -8,9 +8,7 @@ import type { RequestContext } from "../http/context.ts";
import type { NavNode } from "../ui/nav.ts"; import type { NavNode } from "../ui/nav.ts";
import type { StorageCredentials } from "./storage.ts"; import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in. Its major.minor must equal the release tag's, and // The Plainpages release this contract ships in — see README → Contract versioning.
// 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.
export const HOST_API_VERSION = "0.1.0"; export const HOST_API_VERSION = "0.1.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
@@ -156,16 +154,14 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
if (plugin.major !== host.major) { if (plugin.major !== host.major) {
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — incompatible 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) { if (plugin.minor > host.minor) {
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) {
// 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: "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}` };