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
|
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 }}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}` };
|
||||||
|
|||||||
Reference in New Issue
Block a user