Scope the release signal, collapse the contract onto the release version, publish the Hub overview #83
@@ -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 }}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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: <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
|
||||
`Release-Bump: <updateType>` 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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -100,5 +100,5 @@ async function main(): Promise<number> {
|
||||
}
|
||||
|
||||
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
|
||||
process.exit(await main());
|
||||
process.exitCode = await main();
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
Reference in New Issue
Block a user