CI: auto-release a version tag when Renovate merges a dependency update
This commit was merged in pull request #16.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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: <updateType>` 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 +
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 auto-release/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)));
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+3
-2
@@ -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:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"depNameTemplate": "node",
|
||||
"datasourceTemplate": "docker"
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user