# ADR-041 — Renovate runner stand-up: two-token model, OSV surfacing, digest pinning

**Date:** 2026-06-13  
**Status:** Accepted  
**Issue:** [#818](https://git.raddatz.cloud/marcel/familienarchiv/issues/818)

---

## Context

Issue #817 (esbuild/cookie advisory) revealed that `main` had no early-warning
mechanism for newly-published advisories. An advisory landed against already-pinned
versions, turned the `npm audit --audit-level=high --omit=dev` gate red on `main`,
and then ambushed the next unrelated PR (#774). The author who hit it did not cause
it and had no warning.

`renovate.json` existed but `renovatebot` had never actually run: there was no
`.gitea/workflows/renovate.yml` and zero Renovate-authored PRs in the repo's entire
history. The three `packageRules` (bucket4j / tiptap / privileged-digest) were
silently inert.

This ADR records the **negative space** — why specific design choices were made,
so future maintainers do not "tidy up" toward a worse outcome.

---

## Decision

### Why there is no auto-provided `GITEA_TOKEN`

Self-hosted Gitea runners do not auto-inject a `GITEA_TOKEN` equivalent.
`docs/infrastructure/ci-gitea.md` (and its current line ~251) explicitly states the
token "must be created manually." No existing workflow in this repo references
`GITEA_TOKEN` for API calls — only for container registry auth (`docker login`).
Both `RENOVATE_TOKEN` and `NIGHTLY_AUDIT_TOKEN` must be manually provisioned as
Gitea secrets by a repository admin.

### Why two tokens, not one

The two jobs have different blast radii on token compromise:

| Token | Scopes | Used by |
|-------|--------|---------|
| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | Renovate — must read/write files and open PRs |
| `NIGHTLY_AUDIT_TOKEN` | `issues` only | Nightly audit — only needs to file a tracking issue |

The nightly job's token appears in step `env:` and is passed to `curl -H`. A leak via
runner logs, process arguments, or a misconfigured step would expose the token.
An `issues`-only token cannot push branches, open PRs, or read repository contents —
the leaked token's blast radius is limited to creating/editing issues.

A single broad token would give any leak path full `contents` + `pull_request` write
access to the repository. That risk is asymmetric with the upside (one fewer secret).

Both tokens belong to one dedicated bot account (consistent authorship; one identity
to audit and rotate). **Branch protection on `main` must forbid the bot pushing
directly**, because a `contents`-scoped token can push to any unprotected branch.

### Why the Renovate action is digest-pinned

`renovatebot/github-action` executes with the `RENOVATE_TOKEN` in scope. That token
carries `contents` + `pull_request` + `issues` — enough to read files, open PRs, and
write issues. An unpinned `@v40` tag can be re-pointed by the upstream maintainer
(or a compromised maintainer account) at any time. A pinned digest (`@<sha>`) cannot
be silently modified; the SHA is immutable. This is the same threat model applied to
all privileged CI steps in this repo (see the `matchFileNames` rule in `renovate.json`
for `.gitea/workflows/**`).

Renovate itself will open a PR to bump the digest when a new release ships, which is
the intended update path.

### Why `osvVulnerabilityAlerts` is the load-bearing detector on Gitea

Renovate's `vulnerabilityAlerts` config key triggers off a *platform* vulnerability
graph. GitHub exposes the GitHub Advisory Database via its API; **Gitea does not
expose an equivalent vulnerability graph**. On self-hosted Gitea, `vulnerabilityAlerts`
is effectively a label carrier — it attaches the configured labels to PRs that
`osvVulnerabilityAlerts` already detected, but it is not an independent detector.

`osvVulnerabilityAlerts: true` is the load-bearing flag: Renovate queries
[OSV.dev](https://osv.dev) directly (platform-agnostic). The runner host must be able
to reach OSV.dev over HTTPS — if egress is filtered, allow `osv.dev:443` or the flag
silently no-ops.

### Why the root `schedule` does not mute security PRs

`"schedule": ["before 6am on monday"]` in `renovate.json` batches **routine** dependency
updates (version bumps outside any security context) to a weekly window. This reduces
noise from routine update PRs while still allowing review before merge.

**Security and vulnerability PRs bypass the schedule by design** — Renovate raises
them immediately regardless of the schedule window. A future "tidy-up" that removes
or widens the schedule cannot mute vulnerability alerts; this is worth stating
explicitly to prevent that misunderstanding.

### Why `lockFileMaintenance` has no `automerge`

`lockFileMaintenance` refreshes transitive pins weekly so the dependency tree drifts
into fewer advisories over time. It is explicitly set without `automerge: true` because
a weekly transitive pin refresh can silently break the build if a transitive dep
introduces a breaking change. These PRs are small and should be reviewed.

### Why there is no entry in `l2-containers.puml`

`docs/architecture/c4/l2-containers.puml` documents long-lived infrastructure
containers (services that run continuously). Renovate is a scheduled CI job that runs
on a Gitea Actions runner and exits — it is not a long-lived container. Adding it to
the container diagram would misrepresent the architecture. This omission is deliberate,
not an oversight.

---

## Consequences

- Newly-published advisories against our frontend dependencies are surfaced within
  one day (daily Renovate cron) rather than at the next contributor PR.
- A nightly `npm audit` job provides an independent signal for dev-dependency advisories
  that Renovate may not cover via OSV.
- Two secrets (`RENOVATE_TOKEN`, `NIGHTLY_AUDIT_TOKEN`) must be manually provisioned
  and rotated annually (or on suspected compromise). See
  `docs/infrastructure/ci-gitea.md` for the runbook.
- The bot account must be kept active and branch protection on `main` must forbid
  it pushing directly. These are operational prerequisites, not code invariants.
