diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index 8ee6a84..c300140 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -20,3 +20,40 @@ jobs: -e RENOVATE_REPOSITORIES=${{ github.repository }} \ -e RENOVATE_TOKEN \ renovate/renovate:43.251.0 + + # After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the + # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the + # trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit + # (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's + # authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer; + # pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the + # already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't). + auto-release: + runs-on: docker-host + needs: renovate + steps: + - uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + - name: Tag a release for what Renovate merged + env: + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + git fetch --force --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/tags/*:refs/tags/*' + if [ "$(git log -1 --format='%ae' origin/main)" != "renovate@larvit.se" ]; then + echo "main tip not authored by Renovate — a human owns this release; skipping"; exit 0 + fi + LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1) + LATEST=${LATEST:-v0.0.0} + if [ -z "$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' --format='%H')" ]; then + echo "No untagged renovate commits since ${LATEST} — nothing to release"; exit 0 + fi + BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \ + --format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; }) + NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.16.0-alpine3.24 \ + node auto-release/next-version.ts "$LATEST" $BUMPS) + echo "Releasing $LATEST -> $NEXT" + git tag "$NEXT" origin/main + git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT" diff --git a/README.md b/README.md index ee5a583..6450ea1 100644 --- a/README.md +++ b/README.md @@ -1177,7 +1177,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs | `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub | | `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags 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 and automerge them once the gate is green | +| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut one release tag for the run | `main` is not re-tested on push — its commits are meant to arrive already green from a gated branch, so the status check to gate a merge on is `CI / full-gate (push)`. @@ -1255,6 +1255,22 @@ this repo and store its Gitea PAT as the Actions **secret** `RENOVATE_TOKEN`. Un exists, the nightly job fails loud (and, like the other secrets, a `GITEA_` prefix is rejected). +**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs: +renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` +since the last tag (it targets `origin/main`, and **skips** when the tip isn't a Renovate commit — +a human owns that release — or when nothing new merged). Renovate stamps every commit with a +`Release-Bump: ` trailer (`commitBody` in `renovate.json`); the job takes the highest +trailer on those commits — any dependency's `major`/`minor`/`patch` maps straight through, +defaulting to `patch`. +**Pre-1.0 the level shifts down** — a dep major bumps the `0.x` minor, dep minor/patch bump the +`0.x` patch (see [`auto-release/next-version.ts`](auto-release/next-version.ts), unit-tested) — so +routine bumps never auto-cross into `1.0.0`; `1.0.0` stays a deliberate hand-cut tag. It's +**tag-only** (no source commits): the tag hands off to `release.yml`, which promotes the +already-built image, and is pushed with renovate-bot's PAT so `release.yml` actually fires (a tag +pushed by the built-in Actions token wouldn't trigger it). The plugin-contract version +(`HOST_API_VERSION`) is deliberately **not** touched here — it moves only when the plugin API +itself changes, by hand. + **One-time server setup** — register an [act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode with the label `docker-host` (config: `labels: ["docker-host:host"]`) on a machine with Docker Engine + diff --git a/auto-release/next-version.test.ts b/auto-release/next-version.test.ts new file mode 100644 index 0000000..950ec88 --- /dev/null +++ b/auto-release/next-version.test.ts @@ -0,0 +1,43 @@ +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"); +}); + +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/); +}); diff --git a/auto-release/next-version.ts b/auto-release/next-version.ts new file mode 100644 index 0000000..ecc6ad6 --- /dev/null +++ b/auto-release/next-version.ts @@ -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: ` 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 auto-release/next-version.ts [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))); +} diff --git a/package.json b/package.json index b822a4e..b7fafd4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev": "node --watch src/server.ts", "gen-jwks": "node src/auth/gen-jwks.ts", "typecheck": "tsc --noEmit", - "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\"" + "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\"" }, "dependencies": { "@larvit/log": "2.3.0", diff --git a/renovate.json b/renovate.json index d66dbf4..b6327ea 100644 --- a/renovate.json +++ b/renovate.json @@ -2,6 +2,7 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:recommended"], "automerge": true, + "commitBody": "Release-Bump: {{{updateType}}}", "packageRules": [ { "description": "Ory services share one release train - update kratos, keto and hydra together", @@ -26,8 +27,8 @@ }, { "customType": "regex", - "description": "Pin the node image registry-cleanup runs cleanup.ts in", - "managerFilePatterns": [".gitea/workflows/registry-cleanup.yml"], + "description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)", + "managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"], "matchStrings": ["\\snode:(?[0-9][^\\s\"']*)"], "depNameTemplate": "node", "datasourceTemplate": "docker" diff --git a/todo.md b/todo.md index 33b8d83..bffea4c 100644 --- a/todo.md +++ b/todo.md @@ -11,7 +11,8 @@ - [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.) - [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.) - [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.) -- [ ] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. +- [ ] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. +- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.) - [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. - [ ] Build and publish docker image as CI/CD. - [ ] Add i18n support. diff --git a/tsconfig.json b/tsconfig.json index a6d559f..963e76f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "include": ["config", "examples/config", "examples/plugins", "plugins", "registry-cleanup", "src"] + "include": ["auto-release", "config", "examples/config", "examples/plugins", "plugins", "registry-cleanup", "src"] }