Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
60 changes: 60 additions & 0 deletions .github/workflows/renovate-lockfile.yml
Original file line number Diff line number Diff line change
@@ -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
130 changes: 130 additions & 0 deletions apps/fumadocs/content/docs/next/contributing/dependencies.mdx
Original file line number Diff line number Diff line change
@@ -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`.

<Callout type="warn">

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.

</Callout>

## 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.

<Callout type="warn">

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.

</Callout>

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.

<Callout type="warn">

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.

</Callout>

<Callout type="warn">

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.

</Callout>

## 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

<Steps>

<Step>

### Install the app

Install the [Renovate GitHub App](https://github.com/apps/renovate) on the fork, and grant it access to the repository.

</Step>

<Step>

### 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.

</Step>

<Step>

### 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.

</Step>

</Steps>
8 changes: 7 additions & 1 deletion apps/fumadocs/content/docs/next/contributing/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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."
/>

<Card
title="Dependency updates"
href="/docs/contributing/dependencies"
description="What Renovate watches, what it automerges, and why the Bun catalog needs a second job."
/>

<Card
title="Writing documentation"
href="/docs/contributing/writing-docs"
Expand Down
1 change: 1 addition & 0 deletions apps/fumadocs/content/docs/next/contributing/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"data-model",
"categorisation",
"bank-provider",
"dependencies",
"writing-docs",
"releasing"
]
Expand Down
3 changes: 3 additions & 0 deletions docs/engineering/platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,7 @@ Durable facts that the code cannot carry in a name. Each bullet names the module
- `oxslop/no-comments` exempts the `SAFETY:` line itself and nothing after it, so a justification split over two `//` lines fails on the continuation line. A multi-line justification has to be one `/* … */` block.
- `oxslop/require-safety-comment-for-type-assertion` reads a `SAFETY:` comment only on a line the assertion spans, or directly above the statement that holds it. A justification above an object property, a JSX attribute or an expression body inside a longer statement does not count, and `eslint(no-inline-comments)` rules out putting it after the assertion. Bind the assertion to its own `const` and justify that (`packages/api/src/routers/budget.ts › recategoriseTransactions`).
- `oxslop/no-array-filter-map` rejects `filter` next to `map` in either order. A single `flatMap` returning `[value]` or `[]` replaces both passes; the lazy `values().filter().map().toArray()` form also passes the rule, but iterator helpers are newer than the browsers `apps/web` targets.
- `renovate.json5 › customManagers` — Renovate extracts a catalog for pnpm and yarn alone (`lib/modules/manager/npm/extract/common/catalogs.ts`), and its `bun` manager reads the workspace manifests but not `workspaces.catalog`, so the 20 shared versions there would never move. The regex manager stands in: `matchStringsStrategy: "recursive"` passes the whole match of one expression to the next, so the first matches the catalog block and the second each `"name": "range"` inside it. Every `replaceString` it produces is unique in the file because a workspace manifest writes `catalog:` in place of a range. Delete the whole block once renovatebot/renovate#42909 lands, or the catalog has two sources of truth.
- `.github/workflows/renovate-lockfile.yml › lockfile` — artifacts are dispatched per manager from the upgrades that built the branch (`lib/workers/repository/update/branch/get-updated.ts`), so a branch whose only change came from the regex manager never runs `bun install`, and CI then fails with `the catalog in package.json changed since bun.lock was saved`. `postUpgradeTasks` covers exactly this but needs a self-hosted `allowedCommands`, which the Mend app does not offer. The job pushes with `GITHUB_TOKEN` and calls `gh workflow run` afterwards because such a push starts no workflow. `gitIgnoredAuthors` in `renovate.json5` is what stops Renovate reading that commit as a branch somebody else changed, which would freeze the pull request. GitHub exempts `workflow_dispatch` and `repository_dispatch` from that no-recursion rule and nothing else, and it resolves the target against the default branch rather than the ref passed to it, so the call answers 422 until `main` carries the trigger: the step prints an `::error::` naming the branch to start by hand instead of dying without a reason.
- `renovate.json5 › baseBranchPatterns` — Renovate reads the repository config from the default branch alone; `useBaseBranchConfig` defaults to `none`, and naming a base branch does not change where the file is read from. A change to this file, and the `workflow_dispatch` trigger of `ci.yml`, is therefore inert until `dev` reaches `main` at a release. Installing the app before that happens onboards it against `main` with `config:recommended` defaults and no catalog manager at all.
- `jsx-a11y/control-has-associated-label` walks two element levels looking for a control's text: `<button><span>Save</span></button>` passes, and one more wrapper around the text does not. Label text nested deeper needs an explicit `aria-label`.
105 changes: 105 additions & 0 deletions renovate.json5
Original file line number Diff line number Diff line change
@@ -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*\\{[^}]*\\}',
'"(?<depName>[^"]+)"\\s*:\\s*"(?<currentValue>[^"]+)"',
],
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",
},
],
}
Loading