diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 53b8847..b137284 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,10 @@ on:
pull_request:
push:
branches: [dev, main]
+ # .github/workflows/renovate-lockfile.yml calls this after it commits
+ # bun.lock: a push it makes with GITHUB_TOKEN starts no workflow of its own,
+ # and dispatch is what GitHub exempts from that rule.
+ workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
diff --git a/.github/workflows/renovate-lockfile.yml b/.github/workflows/renovate-lockfile.yml
new file mode 100644
index 0000000..a6705ab
--- /dev/null
+++ b/.github/workflows/renovate-lockfile.yml
@@ -0,0 +1,60 @@
+name: Renovate lockfile
+
+# Renovate cannot read a Bun workspace catalog yet, so `renovate.json5` updates
+# the catalog in the root package.json through a regex custom manager. A custom
+# manager writes no lock file, and `bun install --frozen-lockfile` in CI refuses
+# a catalog that moved without bun.lock. This job closes that one gap.
+#
+# The push below carries GITHUB_TOKEN, and a push made with that token starts no
+# workflow. GitHub exempts workflow_dispatch and repository_dispatch from that
+# rule, which is why ci.yml declares the first and why the last step here calls
+# it. GitHub reads that trigger from the default branch, so the call answers 422
+# until this change reaches main; the step says so rather than failing silently.
+
+on:
+ push:
+ branches: ["renovate/**"]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ actions: write
+
+jobs:
+ lockfile:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ # This job writes bun.lock, so it runs the Bun the repository pins rather
+ # than `latest`: another version can rewrite the whole file for its own
+ # format, and that rewrite would ride in on an automerged branch.
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version-file: package.json
+
+ # Resolves the manifests and rewrites bun.lock without installing or
+ # running a postinstall script. It is a no-op on every branch Renovate
+ # already locked for itself.
+ - run: bun install --lockfile-only --ignore-scripts
+
+ - name: Commit bun.lock and start CI on the new commit
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if git diff --quiet -- bun.lock; then
+ echo "bun.lock already matches the manifests"
+ exit 0
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit -m "chore(deps): refresh bun.lock" -- bun.lock
+ git push
+ if ! gh workflow run ci.yml --ref "$GITHUB_REF_NAME"; then
+ echo "::error::bun.lock is committed, but CI did not start on it. GitHub reads the workflow_dispatch trigger from the default branch, so this call works once ci.yml carries it on main. Start CI by hand on $GITHUB_REF_NAME."
+ exit 1
+ fi
diff --git a/apps/fumadocs/content/docs/next/contributing/dependencies.mdx b/apps/fumadocs/content/docs/next/contributing/dependencies.mdx
new file mode 100644
index 0000000..c64da20
--- /dev/null
+++ b/apps/fumadocs/content/docs/next/contributing/dependencies.mdx
@@ -0,0 +1,130 @@
+---
+title: Dependency updates
+description: How Renovate opens dependency pull requests, what it automerges, and why the Bun catalog needs a second job.
+icon: PackageCheck
+---
+
+Renovate keeps the dependencies of this repository current. It reads `renovate.json5` at the root, and it opens every pull request against `dev`.
+
+
+
+Renovate reads that file from the **default branch** alone. `useBaseBranchConfig` defaults to `none`, so a change to `renovate.json5` on `dev` changes nothing until `main` carries it. The same holds for the `workflow_dispatch` trigger of `ci.yml`. Both reach `main` at the next release. Before that, Renovate runs on its own defaults.
+
+
+
+## What it watches
+
+Six managers cover the repository.
+
+| Manager | What it covers |
+| --- | --- |
+| `bun` | Every `package.json` and `bun.lock`, plus the `packageManager` field |
+| `regex` | The 20 shared versions in `workspaces.catalog` of the root `package.json` |
+| `github-actions` | The actions each workflow calls |
+| `dockerfile` | The `oven/bun` base images, and the `docker/dockerfile` syntax line of `apps/server/Dockerfile` |
+| `docker-compose` | `postgres` and `axllent/mailpit` |
+| `nvm` | The Node version in `.nvmrc` |
+
+Two images stay out of reach. Freenary publishes `ghcr.io/suiramdev/freenary-server` and `ghcr.io/suiramdev/freenary-web` itself, and both compose entries name a `FREENARY_VERSION` variable that Renovate skips on its own. `renovate.json5` also disables the two names, which holds if either one ever takes a literal tag.
+
+Check the file itself after you change it:
+
+```bash
+npx --yes --package renovate@latest renovate-config-validator renovate.json5
+```
+
+## The schedule and the merge rules
+
+| Rule | Value |
+| --- | --- |
+| Branch every pull request targets | `dev` |
+| When Renovate runs | Before 6am on Monday, Europe/Paris |
+| Age a release must reach first | 3 days |
+| Open pull requests at once | 10 |
+| Automerge | Patch, minor, pin and digest, after the `ci` and `compose` checks pass |
+| Review by hand | Every major update, and every package pinned to a prerelease |
+| Lock file maintenance | Before 6am on the first day of the month |
+
+A security advisory ignores the schedule and the three-day wait. Those pull requests carry the `security` label.
+
+
+
+CI runs no tests, so a green `ci` check proves that the repository compiles, not that it works. Automerge rests on the build and on the three-day wait alone. Read the dependency dashboard each week.
+
+
+
+A package pinned to a prerelease never automerges. `effect` sits at `4.0.0-rc.115`, and Renovate reads `rc.115` to `rc.116` as a patch. The update type alone would let an unstable version of a core library into `dev`.
+
+A PostgreSQL major update opens no pull request. Such a major needs a dump and a restore of the database. It waits for your approval on the dependency dashboard first.
+
+## The dependency dashboard
+
+Renovate keeps one open issue titled **Dependency Dashboard**. It lists every update it holds back, every pull request it opened, and every failure. Tick a box there to ask for a branch at once.
+
+## The Bun catalog needs a second job
+
+The root `package.json` holds a `workspaces.catalog` block. It pins one version for each of the 20 packages that more than one workspace depends on. Each workspace then writes `catalog:` in place of a range.
+
+Renovate reads a catalog for pnpm and for yarn, and it reads no catalog for Bun. Without help, those 20 versions stay frozen. `renovate.json5` therefore declares a regex custom manager. It matches the block, then each entry inside it. Such a manager writes no lock file. A catalog pull request lands with a stale `bun.lock`, and step 1 of CI fails:
+
+```text
+error: lockfile had changes, but lockfile is frozen
+note: the catalog in package.json changed since bun.lock was saved
+```
+
+`.github/workflows/renovate-lockfile.yml` closes that gap. On every push to a `renovate/**` branch it runs one command, and it commits the result when the file moved:
+
+```bash
+bun install --lockfile-only --ignore-scripts
+```
+
+The job then calls `gh workflow run ci.yml`. A push made with `GITHUB_TOKEN` starts no workflow. GitHub exempts the two dispatch events from that rule, and `ci.yml` declares `workflow_dispatch` for this one caller. Pull requests from this manager carry the `catalog` label.
+
+
+
+GitHub reads the `workflow_dispatch` trigger from the default branch. The call above answers 422 until `main` carries it, which happens at the next release. Until then, a catalog pull request keeps its lock file commit but starts no CI run. Start the **CI** workflow by hand on that branch, and the job log says the same.
+
+
+
+
+
+Delete the custom manager, this workflow, and the `workflow_dispatch` trigger of `ci.yml` on the day Renovate gains Bun catalog support. Until then the catalog has two readers, and a change to the block shape can break the second one.
+
+
+
+## What a reviewer does with these pull requests
+
+- Read the release notes Renovate puts in the body. It links the diff and the changelog of each version.
+- A major update names its breaking changes. Run the stack and the checks of [Contributing](/docs/contributing) before you merge one.
+- A red `ci` check has ordinary causes: a type error or a broken build from the new version. On a catalog branch, read the **Renovate lockfile** job first, because a missing lock file commit fails step 1.
+- Never push to a Renovate branch by hand. Renovate rebases it, and your commit disappears.
+
+## Enable it on a fork
+
+
+
+
+
+### Install the app
+
+Install the [Renovate GitHub App](https://github.com/apps/renovate) on the fork, and grant it access to the repository.
+
+
+
+
+
+### Turn on automerge
+
+Open **Settings → General** and turn on **Allow auto-merge**. Renovate then hands each merge to GitHub. Add a ruleset on your own `dev` branch that needs the `ci` and `compose` checks. Without one, GitHub merges with nothing to wait for.
+
+
+
+
+
+### Answer the onboarding pull request
+
+Renovate opens one pull request that names the updates it found. Merge it, and Renovate starts on the schedule above.
+
+
+
+
diff --git a/apps/fumadocs/content/docs/next/contributing/index.mdx b/apps/fumadocs/content/docs/next/contributing/index.mdx
index 27df29a..7391562 100644
--- a/apps/fumadocs/content/docs/next/contributing/index.mdx
+++ b/apps/fumadocs/content/docs/next/contributing/index.mdx
@@ -70,7 +70,7 @@ One line separates them. The containerised path runs every part in Docker, behin
## The checks before a pull request
-`.github/workflows/ci.yml` declares two jobs, `ci` and `compose`. GitHub starts both on every pull request, and on every push to `dev` and `main`. The `ci` timeout is 10 minutes and the `compose` timeout is 5 minutes.
+`.github/workflows/ci.yml` declares two jobs, `ci` and `compose`. GitHub starts both on every pull request, and on every push to `dev` and `main`. The file also declares `workflow_dispatch`, which [Dependency updates](/docs/contributing/dependencies) explains. The `ci` timeout is 10 minutes and the `compose` timeout is 5 minutes.
The `ci` job runs seven commands. Run the same seven before you open a pull request:
@@ -269,6 +269,12 @@ Each workspace states its own rules next to its code:
description="The five services, the hostnames, the database commands and the resets."
/>
+
+
Save` passes, and one more wrapper around the text does not. Label text nested deeper needs an explicit `aria-label`.
diff --git a/renovate.json5 b/renovate.json5
new file mode 100644
index 0000000..4aeafae
--- /dev/null
+++ b/renovate.json5
@@ -0,0 +1,105 @@
+{
+ $schema: "https://docs.renovatebot.com/renovate-schema.json",
+ extends: ["config:recommended", ":semanticCommits"],
+
+ // Pull requests target the integration branch, like every other one. Renovate
+ // reads this file from the default branch alone (useBaseBranchConfig defaults
+ // to "none"), so a change here is live once it reaches main.
+ baseBranchPatterns: ["dev"],
+ timezone: "Europe/Paris",
+ schedule: ["before 6am on monday"],
+ labels: ["dependencies"],
+ prConcurrentLimit: 10,
+
+ // A release that is pulled or patched within three days never reaches a
+ // branch here, which is what makes the automerge below safe to leave on.
+ minimumReleaseAge: "3 days",
+
+ // .github/workflows/renovate-lockfile.yml commits bun.lock on Renovate's own
+ // branches. Without this, Renovate reads that commit as a branch someone else
+ // changed and stops updating and merging the pull request.
+ gitIgnoredAuthors: ["41898282+github-actions[bot]@users.noreply.github.com"],
+
+ lockFileMaintenance: {
+ enabled: true,
+ schedule: ["before 6am on the first day of the month"],
+ },
+
+ vulnerabilityAlerts: {
+ labels: ["dependencies", "security"],
+ schedule: ["at any time"],
+ minimumReleaseAge: null,
+ },
+
+ customManagers: [
+ {
+ // Renovate has no Bun catalog support yet, so the 20 shared versions in
+ // workspaces.catalog are extracted by hand. docs/engineering/platform.md
+ // carries why, and what to delete once upstream lands it.
+ description: "The Bun workspace catalog in the root package.json",
+ customType: "regex",
+ managerFilePatterns: ["/^package\\.json$/"],
+ matchStringsStrategy: "recursive",
+ matchStrings: [
+ '"catalog"\\s*:\\s*\\{[^}]*\\}',
+ '"(?[^"]+)"\\s*:\\s*"(?[^"]+)"',
+ ],
+ datasourceTemplate: "npm",
+ versioningTemplate: "npm",
+ },
+ ],
+
+ packageRules: [
+ {
+ description: "Everything below a major lands on its own once CI is green",
+ matchUpdateTypes: ["minor", "patch", "pin", "digest"],
+ automerge: true,
+ },
+ {
+ description: "A catalog pull request needs the bun.lock job to run on it",
+ matchManagers: ["custom.regex"],
+ addLabels: ["catalog"],
+ },
+ {
+ description: "Freenary publishes these two images itself. Both compose entries carry a ${FREENARY_VERSION} variable today, which Renovate skips on its own; this holds if either is ever pinned to a literal tag",
+ matchDatasources: ["docker"],
+ matchPackageNames: [
+ "ghcr.io/suiramdev/freenary-server",
+ "ghcr.io/suiramdev/freenary-web",
+ ],
+ enabled: false,
+ },
+ {
+ description: "A PostgreSQL major needs a dump and a restore, so it waits on the dashboard instead of opening a pull request",
+ matchDatasources: ["docker"],
+ matchPackageNames: ["postgres"],
+ matchUpdateTypes: ["major"],
+ dependencyDashboardApproval: true,
+ },
+ {
+ description: "better-auth and its plugins are pinned to one exact version",
+ groupName: "better-auth",
+ matchPackageNames: ["better-auth", "@better-auth/**"],
+ },
+ {
+ description: "The pinned Bun version: the packageManager field and the images that must match it. oven-sh/setup-bun is the action version, not the Bun version, so it stays with the other actions",
+ groupName: "bun",
+ matchPackageNames: ["bun", "oven/bun"],
+ },
+ {
+ // effect sits at 4.0.0-rc.115, and Renovate reads rc.115 to rc.116 as a
+ // patch. CI runs no tests, so an unstable version of a library the API is
+ // built on must not reach dev on the build alone.
+ description: "A package pinned to a prerelease stays under review, whatever the update type says",
+ matchDatasources: ["npm"],
+ matchCurrentValue: "/-(?:alpha|beta|canary|dev|next|nightly|pre|rc)\\b/",
+ automerge: false,
+ },
+ {
+ description: "The workflow actions move together",
+ matchManagers: ["github-actions"],
+ groupName: "github actions",
+ semanticCommitType: "ci",
+ },
+ ],
+}