Release this as v0.2.0, and give the Hub overview its own job, token and template
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
test("the shipped tag and the shipped contract agree", () => {
|
||||
// Guards the pair the release gate checks, so a bump to one fails here before it fails in CI.
|
||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0");
|
||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
||||
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
||||
// 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", () => {
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
|
||||
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.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("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
|
||||
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
|
||||
assert.equal(checkTagMatchesContract("v0.2.0", "0.2.0").ok, true);
|
||||
assert.equal(checkTagMatchesContract("v0.2.7", "0.2.0").ok, true); // auto-release cut patches
|
||||
assert.equal(checkTagMatchesContract("0.2.0", "0.2.0").ok, true); // bare tag, no v
|
||||
assert.equal(checkTagMatchesContract("v0.3.0", "0.2.0").ok, false); // plugin-visible, needs a bump
|
||||
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", () => {
|
||||
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
|
||||
const res = checkTagMatchesContract("v0.3.0", "0.2.0");
|
||||
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", () => {
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
|
||||
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
|
||||
assert.equal(checkTagMatchesContract("v0.2.0", null).ok, false); // constant not found
|
||||
assert.equal(checkTagMatchesContract("nope", "0.2.0").ok, false);
|
||||
assert.equal(checkTagMatchesContract("v0.2.0", "1.0").ok, false);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export function readHostApiVersion(source: string): string | null {
|
||||
// releases with no commit to bump the constant in.
|
||||
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
|
||||
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);
|
||||
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")) {
|
||||
const [, , tag, pluginPath] = process.argv;
|
||||
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
|
||||
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));
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`${result.error}\n`);
|
||||
process.stderr.write(`${pluginPath}: ${result.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Plainpages
|
||||
|
||||
A self-hostable foundation for server-rendered web apps — public or gated pages from a
|
||||
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
|
||||
Every domain feature is a drop-in plugin folder, with a Postgres database of its own if it wants
|
||||
one; the host itself is stateless, and there is no build step.
|
||||
|
||||
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
|
||||
([GitHub mirror](https://github.com/larvit/plainpages))
|
||||
|
||||
## Tags
|
||||
|
||||
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
|
||||
Pin the exact `X.Y.Z` you deploy.
|
||||
|
||||
## Quick start
|
||||
|
||||
This image is the Plainpages web app plus its one-shot bootstrap seeder. It runs
|
||||
alongside its Ory sidecars (Kratos, Keto) and Postgres — and it **ships their config**,
|
||||
so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: larvit/plainpages:{{VERSION}}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
APP_URL: http://localhost:3000
|
||||
depends_on:
|
||||
bootstrap:
|
||||
condition: service_completed_successfully
|
||||
kratos:
|
||||
condition: service_healthy
|
||||
keto:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||
- ./plugins:/app/plugins
|
||||
restart: unless-stopped
|
||||
|
||||
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||
bootstrap:
|
||||
image: larvit/plainpages:{{VERSION}}
|
||||
command: node src/auth/bootstrap.ts
|
||||
depends_on:
|
||||
kratos:
|
||||
condition: service_healthy
|
||||
keto:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
||||
- ./plugins:/app/plugins:ro
|
||||
restart: "on-failure:5"
|
||||
|
||||
postgres:
|
||||
image: postgres:18.4-alpine3.23
|
||||
environment:
|
||||
POSTGRES_DB: ory
|
||||
POSTGRES_PASSWORD: ory
|
||||
POSTGRES_USER: ory
|
||||
volumes:
|
||||
- ./ory/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
- pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ory -d ory"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
kratos-migrate:
|
||||
image: oryd/kratos:v26.2.0
|
||||
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
||||
volumes:
|
||||
- ./ory/kratos:/etc/config/kratos:ro
|
||||
restart: on-failure
|
||||
|
||||
kratos:
|
||||
image: oryd/kratos:v26.2.0
|
||||
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
|
||||
ports:
|
||||
- "4433:4433" # the login form POSTs straight to Kratos from the browser
|
||||
depends_on:
|
||||
kratos-migrate:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
||||
volumes:
|
||||
- ./ory/kratos:/etc/config/kratos:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
keto-migrate:
|
||||
image: oryd/keto:v26.2.0
|
||||
command: -c /etc/config/keto/keto.yml migrate up -y
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
||||
volumes:
|
||||
- ./ory/keto:/etc/config/keto:ro
|
||||
restart: on-failure
|
||||
|
||||
keto:
|
||||
image: oryd/keto:v26.2.0
|
||||
command: serve -c /etc/config/keto/keto.yml
|
||||
depends_on:
|
||||
keto-migrate:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
||||
volumes:
|
||||
- ./ory/keto:/etc/config/keto:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.1
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
Extract the Ory config the image ships, then start:
|
||||
|
||||
```bash
|
||||
docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf -
|
||||
mkdir -p plugins
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open <http://localhost:3000> and sign in as `admin@plainpages.local` / `admin`.
|
||||
|
||||
This quick start runs http-on-localhost with dev-throwaway secrets, and omits Hydra (the
|
||||
OAuth2 provider — only needed when other apps log in *through* Plainpages). For
|
||||
production — https, real secrets (`CSRF_SECRET`, Postgres credentials, a fresh JWT
|
||||
signing key), Hydra — see the repo README → Production & deployment.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The common ones:
|
||||
|
||||
| Var | Default | What |
|
||||
| --- | --- | --- |
|
||||
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
|
||||
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
|
||||
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
|
||||
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
|
||||
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
|
||||
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
|
||||
| `OTLP_ENDPOINT` | unset | export logs + traces to an OpenTelemetry Collector |
|
||||
| `REQUIRE_SECURE_SECRETS` | `false` | `true` ⇒ refuse to boot on a missing/throwaway `CSRF_SECRET` |
|
||||
| `SECURE_COOKIES` | `false` | mark cookies `Secure` (`true` behind https) |
|
||||
|
||||
Full list (JWT/JWKS, timeouts, instant revoke): repo README → Configuration.
|
||||
|
||||
## Your first plugin
|
||||
|
||||
Everything domain-specific is a plugin folder — the compose above mounts `./plugins`
|
||||
into the app. Create `plugins/hello/plugin.ts`:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.2.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
|
||||
forms, permissions, and the runnable reference plugin: repo README → Building plugins.
|
||||
@@ -13,8 +13,8 @@ test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes cle
|
||||
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
|
||||
});
|
||||
|
||||
test("the real README-dockerhub.md renders clean and pins no literal image tag", () => {
|
||||
const rendered = renderOverview(readFileSync("README-dockerhub.md", "utf8"), "9.9.9");
|
||||
test("the real template renders clean and pins no literal image tag", () => {
|
||||
const rendered = renderOverview(readFileSync("release-tooling/dockerhub-overview.md.tmpl", "utf8"), "9.9.9");
|
||||
assert.deepEqual(leftoverPlaceholders(rendered), []);
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Publishes README-dockerhub.md as the Docker Hub repository overview. The page is the first thing
|
||||
// an adopter copies, and its image tags were maintained by hand — they pointed at a version that no
|
||||
// longer existed. `{{VERSION}}` is rendered from the tag being promoted, so they cannot go stale.
|
||||
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl. The page is the
|
||||
// first thing an adopter copies, and its image tags were maintained by hand — they pointed at a
|
||||
// version that no longer existed. `{{VERSION}}` is rendered from the release, so they cannot.
|
||||
|
||||
const HUB = "https://hub.docker.com/v2";
|
||||
|
||||
@@ -18,16 +18,18 @@ async function main(): Promise<number> {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const repo = process.env["DOCKERHUB_REPO"];
|
||||
const user = process.env["DOCKERHUB_USER"];
|
||||
const token = process.env["DOCKERHUB_TOKEN"];
|
||||
const token = process.env["DOCKERHUB_OVERVIEW_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;
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -56,8 +58,8 @@ async function main(): Promise<number> {
|
||||
process.stderr.write(
|
||||
`Docker Hub overview PATCH failed: ${res.status} ${detail}\n` +
|
||||
(res.status === 403
|
||||
? "403 usually means the token is scoped to the repository's images only — publishing the " +
|
||||
"overview edits repository metadata and needs a token with that permission (README -> CI/CD).\n"
|
||||
? "403 means DOCKERHUB_OVERVIEW_TOKEN cannot edit repository metadata — that is a separate " +
|
||||
"permission from pushing images, which is why it is its own secret (README -> CI/CD).\n"
|
||||
: ""),
|
||||
);
|
||||
return 1;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { bumpFromUpdateType, maxLevel, nextVersion } from "./next-version.ts";
|
||||
|
||||
test("bumpFromUpdateType: only major/minor keep their level; everything else is patch", () => {
|
||||
assert.equal(bumpFromUpdateType("major"), "major");
|
||||
assert.equal(bumpFromUpdateType("minor"), "minor");
|
||||
assert.equal(bumpFromUpdateType("patch"), "patch");
|
||||
assert.equal(bumpFromUpdateType("digest"), "patch");
|
||||
assert.equal(bumpFromUpdateType("pin"), "patch");
|
||||
assert.equal(bumpFromUpdateType("lockFileMaintenance"), "patch");
|
||||
assert.equal(bumpFromUpdateType(""), "patch");
|
||||
});
|
||||
|
||||
test("maxLevel: defaults to patch, escalates on the highest level present", () => {
|
||||
assert.equal(maxLevel([]), "patch");
|
||||
assert.equal(maxLevel(["patch"]), "patch");
|
||||
assert.equal(maxLevel(["patch", "minor"]), "minor");
|
||||
assert.equal(maxLevel(["minor", "major", "patch"]), "major");
|
||||
assert.equal(maxLevel(["digest", "pin"]), "patch");
|
||||
assert.equal(maxLevel(["", "bogus"]), "patch"); // unknown → patch, never throws
|
||||
});
|
||||
|
||||
test("nextVersion pre-1.0 (major===0): shift down so we never auto-cross into 1.0.0", () => {
|
||||
// dep major → 0.x minor (the 0.x "breaking" slot); dep minor/patch → 0.x patch
|
||||
assert.equal(nextVersion("v0.0.2", "major"), "v0.1.0");
|
||||
assert.equal(nextVersion("v0.0.2", "minor"), "v0.0.3");
|
||||
assert.equal(nextVersion("v0.0.2", "patch"), "v0.0.3");
|
||||
assert.equal(nextVersion("v0.3.4", "major"), "v0.4.0");
|
||||
assert.equal(nextVersion("v0.3.4", "minor"), "v0.3.5");
|
||||
assert.equal(nextVersion("v0.3.4", "patch"), "v0.3.5");
|
||||
});
|
||||
|
||||
test("nextVersion at/after 1.0.0: literal semver", () => {
|
||||
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
|
||||
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
|
||||
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
|
||||
// the whole chain: a major dependency bump releases a major host, once the 0.x shift-down is gone
|
||||
assert.equal(nextVersion("v1.2.3", maxLevel(["patch", "major"])), "v2.0.0");
|
||||
});
|
||||
|
||||
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
|
||||
assert.throws(() => nextVersion("1.2.3", "patch"), /vX\.Y\.Z/);
|
||||
assert.throws(() => nextVersion("vx.y.z", "patch"), /vX\.Y\.Z/);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
||||
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
||||
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
||||
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
||||
|
||||
export type Bump = "major" | "minor" | "patch";
|
||||
|
||||
// A dependency change is always at least a patch; only a real major/minor escalates.
|
||||
export function bumpFromUpdateType(updateType: string): Bump {
|
||||
if (updateType === "major") return "major";
|
||||
if (updateType === "minor") return "minor";
|
||||
return "patch";
|
||||
}
|
||||
|
||||
export function maxLevel(updateTypes: string[]): Bump {
|
||||
let level: Bump = "patch";
|
||||
for (const updateType of updateTypes) {
|
||||
const bump = bumpFromUpdateType(updateType);
|
||||
if (bump === "major") return "major";
|
||||
if (bump === "minor") level = "minor";
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
// Pre-1.0 (major===0) shifts every level down one notch, so a dependency major only bumps the 0.x
|
||||
// minor and we never auto-cross into 1.0.0 — that stays a deliberate human milestone.
|
||||
export function nextVersion(latestTag: string, level: Bump): string {
|
||||
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(latestTag);
|
||||
if (!match) throw new Error(`latest tag must be vX.Y.Z, got ${JSON.stringify(latestTag)}`);
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
const patch = Number(match[3]);
|
||||
const effective: Bump = major === 0 ? (level === "major" ? "minor" : "patch") : level;
|
||||
if (effective === "major") return `v${major + 1}.0.0`;
|
||||
if (effective === "minor") return `v${major}.${minor + 1}.0`;
|
||||
return `v${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
// CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
||||
if (process.argv[1]?.endsWith("/next-version.ts")) {
|
||||
const [, , latestTag, ...updateTypes] = process.argv;
|
||||
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
||||
}
|
||||
Reference in New Issue
Block a user