Run 350 cleared platform init (/user 200, /version 200, /repos 200), then stopped at GET /api/v1/orgs/Home-Network with Gitea's own 403: token does not have at least one of required scope(s), required=[read:organization], token scope=read:package,write:repository,read:user The workflow's header listed only those three scopes, and the operator minted exactly that. Renovate's initRepo -> isOrg needs read:organization, and Gitea files /issues and /labels under the issue scope, which the dashboard and PR labels need: write:issue. - renovate.yaml: corrects the scope list (read:user, read:organization, write:repository, write:issue, read:package), with the call each serves. A new preflight step probes them with GETs, the token passed to curl on stdin, and names a missing scope instead of Renovate's bare "Authentication failure". - ci/lint.sh: rejects workflow `uses:` lines that need a Docker daemon (docker://, renovatebot/github-action, docker/*-action), the class fixed in #84. Co-Authored-By: Claude Opus 5.5 <[email protected]>
319 lines
21 KiB
YAML
319 lines
21 KiB
YAML
# Renovate — the dependency/image update automation. SCHEDULED, self-hosted against this Gitea.
|
|
#
|
|
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
# WHY THIS FILE EXISTS: renovate.json HAS BEEN IN THIS REPO AND HAS NEVER ONCE RUN.
|
|
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
# Measured 2026-10-17: `renovate.json` is present, complete and thoughtfully written — it declares
|
|
# managers, schedules, automerge classes and grouping. Nothing executes it. There is no Renovate
|
|
# workflow (this is the first), no Renovate CronJob or Deployment in the cluster
|
|
# (`kubectl get cronjob,deploy,job -A | grep -i renovate` -> nothing), and no bot account doing it
|
|
# from outside. Meanwhile SEVEN files carry comments asserting "Renovate maintains this pin"
|
|
# (platform/homepage/deployment.yaml:40, runbooks/serving/deployment.yaml:87,
|
|
# platform/crafty/deployment.yaml:63, platform/vpn-ingress/daemonset.yaml:259,
|
|
# platform/keycloak/postgres-cluster.yaml:75, apps/wave-0/nextcloud-db.yaml:302, and
|
|
# platform/headlamp/helmrepository.yaml:2). Those comments have been false for the life of the file.
|
|
#
|
|
# The cost is measurable. With nothing bumping anything: the six per-app oauth2-proxy auth sidecars
|
|
# sit on v7.8.1 while the platform oauth2-proxy in the SAME cluster runs v7.15.5; Keycloak (the IdP)
|
|
# runs 26.0.6, a November-2024 release, 8 minors behind; Velero runs a RELEASE CANDIDATE
|
|
# (velero-plugin-for-aws v1.14.3-rc.2) with a stable available; the CNPG Postgres image is 16.4,
|
|
# missing every 16.5+ security release. A pin nothing maintains is not a pin, it is a fossil.
|
|
#
|
|
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
# WHY RENOVATE AND NOT FLUX IMAGE-AUTOMATION (the decision, and it reverses ADR-0009's split)
|
|
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
# ADR-0009 divided the work: Renovate owns Helm chart/dependency bumps, Flux image-automation owns
|
|
# per-service container image TAGS, deliberately non-overlapping. The second half of that split
|
|
# cannot be delivered on this repo's terms, for three measured reasons:
|
|
#
|
|
# 1. IT WOULD BYPASS THE MERGE GATE, WHICH CLAUDE.md §1 FORBIDS. ImageUpdateAutomation does not
|
|
# open pull requests; it COMMITS AND PUSHES. Its default target is the source's own branch —
|
|
# `main` — so it would write straight past the PR flow. It can be pointed at a side branch
|
|
# instead, but then nothing opens the PR and bumps silently accumulate on a branch nobody
|
|
# merges. Either way a human or a second bot has to close the loop.
|
|
# 2. ON THIS CLUSTER IT CANNOT EVEN DO THAT. Measured 2026-10-17: `main` is protected with
|
|
# `user_can_push: false`, and the `flux-system` GitRepository has NO `spec.secretRef` at all —
|
|
# the source is anonymous and read-only. Enabling write-back means minting an in-cluster git
|
|
# WRITE credential for the repository that defines the cluster, which is a materially larger
|
|
# blast radius than a CI-scoped token.
|
|
# 3. RENOVATE ALREADY OPENS PRs NATIVELY, so every change it proposes goes through the gate with
|
|
# no extra machinery.
|
|
#
|
|
# So: Renovate owns BOTH halves, and Flux image-automation stays disabled. Note this is a change of
|
|
# plan, not a change of state — image-automation has been commented out at
|
|
# platform/kustomization.yaml:52 since it was authored, and the cluster has ZERO
|
|
# ImageRepository/ImagePolicy/ImageUpdateAutomation objects (measured). Its two controllers ARE
|
|
# installed and have idled for 43 days. Nothing is being torn down here; a path that was never
|
|
# taken is being formally closed, and renovate.json's `docker`/`kubernetes` managers — disabled
|
|
# purely to avoid fighting a controller that never ran — are switched on to fill the gap.
|
|
#
|
|
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
# THIS WORKFLOW MUST NOT BE ABLE TO SUCCEED SILENTLY
|
|
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
# A SKIPPED Gitea job reports `success` (CLAUDE.md §2), so "green" is not evidence this ran. Two
|
|
# guards:
|
|
# * The preflight step FAILS HARD (exit 1) if RENOVATE_TOKEN is absent, rather than skipping.
|
|
# A missing credential on a dependency bot is indistinguishable from "nothing to update", and
|
|
# that is precisely how renovate.json stayed inert for months.
|
|
# * The summary step records what ran into the job summary unconditionally.
|
|
# What this still does NOT give you is an alert when the SCHEDULE stops firing. See the handoff
|
|
# entry's "What I did NOT verify" — that gap is stated, not papered over.
|
|
#
|
|
# OPERATOR PREREQUISITE — ONE SECRET, NOT COMMITTED HERE:
|
|
# `RENOVATE_TOKEN` — a Gitea access token for the bot account (`renovate-bot`) with FIVE scopes.
|
|
# Each is required by a call Renovate 44.x makes against Gitea 1.24.3, and Gitea refuses a
|
|
# token missing one with `403 token does not have at least one of required scope(s)`:
|
|
# read:user GET /user (platform init: initPlatform)
|
|
# read:organization GET /orgs/{owner}, /orgs/{o}/labels (initRepo -> isOrg, then org labels)
|
|
# write:repository clone/push branches, /pulls, /statuses
|
|
# write:issue /issues, /labels, /issues/{n}/labels (dependency dashboard, PR labels;
|
|
# Gitea files all of these under the ISSUE scope, not repository)
|
|
# read:package the Gitea container registry (RENOVATE_HOST_RULES below)
|
|
# CORRECTED 2026-10-30. This list used to name only write:repository + read:user (+ read:package),
|
|
# and the operator minted exactly that. Run 350 then failed at
|
|
# `GET /api/v1/orgs/Home-Network 403 ... required=[read:organization], token scope=read:package,
|
|
# write:repository,read:user`; write:issue would have been the next wall. The preflight below now
|
|
# PROBES these scopes, so a short token fails in seconds with the missing scope named.
|
|
# Set it as an Actions secret on this repository. Nothing in this repo mints,
|
|
# stores or references its value; it is consumed by name only.
|
|
# Until it exists, this workflow FAILS LOUDLY on every scheduled run. That is intended: a
|
|
# visible red is the correct state for "automation configured but not credentialed".
|
|
#
|
|
# THE FIRST RUN WILL WANT TO OPEN A LOT OF PRs. ~30 dependencies are behind. renovate.json caps the
|
|
# blast radius (`prConcurrentLimit: 5`, `prHourlyLimit: 2`), but run it manually with
|
|
# `dry_run: true` FIRST and read the log before letting the schedule loose. That is what
|
|
# workflow_dispatch's default is for.
|
|
name: renovate
|
|
|
|
on:
|
|
schedule:
|
|
# 08:15 UTC daily. renovate.json ALSO carries `"schedule": ["after 3am and before 6am"]` with
|
|
# `"timezone": "America/New_York"`, and THAT inner schedule is authoritative — Renovate re-checks
|
|
# it and DECLINES TO OPEN PRs outside it. So this cron must fire INSIDE the local window, not
|
|
# merely near it.
|
|
#
|
|
# WAS `15 3 * * *`, WHICH NEVER WOULD HAVE WORKED (fixed 2026-10-18, before the first scheduled
|
|
# run). Gitea Actions cron is UTC, so 03:15 UTC is 23:15 the PREVIOUS EVENING in New York --
|
|
# 3h45m BEFORE the window opens, not "slightly ahead" of it as the original comment claimed.
|
|
# Every nightly run would have started, logged "not within schedule", opened zero PRs and
|
|
# exited SUCCESSFULLY. A green tick forever, and no updates ever: the exact "failure mode is a
|
|
# green tick" shape this repo keeps getting burned by, and indistinguishable from "nothing
|
|
# needed updating".
|
|
#
|
|
# THE WINDOW IN UTC MOVES WITH US DST, and a cron cannot follow it:
|
|
# EDT (UTC-4, Mar-Nov): local 03:00-06:00 == 07:00-10:00 UTC
|
|
# EST (UTC-5, Nov-Mar): local 03:00-06:00 == 08:00-11:00 UTC
|
|
# 08:15 UTC is the only quarter-hour comfortably inside BOTH (04:15 EDT / 03:15 EST), so the
|
|
# schedule keeps working across both switchovers without an edit. Do not "tidy" this back to an
|
|
# early-morning-UTC value without redoing this arithmetic.
|
|
- cron: "15 8 * * *"
|
|
workflow_dispatch:
|
|
inputs:
|
|
dry_run:
|
|
description: "Dry run (no PRs, no branches). Leave true for the first run."
|
|
required: false
|
|
default: "true"
|
|
log_level:
|
|
description: "Renovate LOG_LEVEL (info | debug)"
|
|
required: false
|
|
default: "info"
|
|
|
|
concurrency:
|
|
# Never two Renovate runs at once: concurrent runs race on the same branches and can open
|
|
# duplicate PRs. Not cancel-in-progress — a half-finished Renovate run can leave a branch pushed
|
|
# with no PR, so let the first one finish.
|
|
group: renovate
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
renovate:
|
|
runs-on: ubuntu-latest
|
|
# RENOVATE RUNS AS THE JOB CONTAINER, NOT THROUGH renovatebot/github-action. FIXED 2026-10-30.
|
|
# That action does exactly one thing: `docker run ghcr.io/renovatebot/renovate:<version>`. On
|
|
# this runner a job has NO Docker daemon, by decision (decisions/2026-10-29-image-pipeline.md:
|
|
# act_runner sets container.docker_host to `-` when DOCKER_HOST is tcp://, and the DinD daemon
|
|
# is privileged, so a job that could reach it would be root on the node). So every run of the
|
|
# action died before Renovate started. Dispatch run 343 (2026-10-30), verbatim:
|
|
# [command]/usr/bin/docker run --env LOG_LEVEL ... --env RENOVATE_TOKEN=*** ...
|
|
# failed to connect to the docker API at unix:///var/run/docker.sock; ... no such file or directory
|
|
# ::error::Error: The process '/usr/bin/docker' failed with exit code 1
|
|
# No auth, scope, config or network question was ever reached. Making the Renovate image the
|
|
# job container needs no daemon: act starts it through the runner's own DinD exactly as it
|
|
# starts the default ubuntu image, then `exec`s each step inside it.
|
|
#
|
|
# PINNED here (was the action's `renovate-version: 44.167.0`). renovate.json5 is validated
|
|
# against this exact tag — keep them in step. Renovate's github-actions manager reads
|
|
# `.gitea/workflows` and extracts `container.image`, so this pin is still self-maintained.
|
|
#
|
|
# The image runs as uid 12021 (`ubuntu`), and that is fine under act: the step command files
|
|
# ($GITHUB_STEP_SUMMARY etc.) are created mode 0666 (gitea/act v0.261.2 pkg/runner/step.go),
|
|
# HOME comes from the image env, and no step writes the workspace. There is deliberately NO
|
|
# actions/checkout: Renovate clones the repo itself from RENOVATE_ENDPOINT, and as uid 12021
|
|
# checkout could not `git init` the root-owned workspace volume anyway.
|
|
container:
|
|
image: ghcr.io/renovatebot/renovate:44.167.0
|
|
# Renovate scans every manager across the whole repo; 40m is generous for this repo's size and
|
|
# well under a runner's patience. Without a timeout a wedged run holds the `renovate`
|
|
# concurrency group and silently blocks every subsequent scheduled run.
|
|
timeout-minutes: 40
|
|
steps:
|
|
- name: Preflight - the token must exist, and its absence must be LOUD
|
|
shell: bash
|
|
env:
|
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${RENOVATE_TOKEN:-}" ]; then
|
|
echo "::error::RENOVATE_TOKEN is not set on this repository." >&2
|
|
cat >&2 <<'MSG'
|
|
|
|
Renovate cannot run without a Gitea access token, and this step fails rather than
|
|
skipping ON PURPOSE: a skipped Gitea job reports `success`, so skipping here would make
|
|
"dependency automation is not running" look exactly like "everything is up to date".
|
|
That is the failure mode that let renovate.json sit inert in this repo for months while
|
|
seven files claimed "Renovate maintains this pin".
|
|
|
|
TO FIX: create a Gitea access token for the bot account (renovate-bot) with scopes
|
|
read:user, read:organization, write:repository, write:issue and read:package (the
|
|
header of .gitea/workflows/renovate.yaml says which call needs which), then add it to
|
|
this repository as the Actions secret RENOVATE_TOKEN. Do not commit it anywhere.
|
|
MSG
|
|
exit 1
|
|
fi
|
|
echo "RENOVATE_TOKEN is present (value not printed)."
|
|
# Proves the step is running INSIDE the pinned Renovate image, not the default job image.
|
|
echo "renovate $(renovate --version)"
|
|
|
|
- name: Preflight - the token has every scope Renovate needs
|
|
shell: bash
|
|
env:
|
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
|
# ADDED 2026-10-30. Renovate reports EVERY platform-init failure as the bare
|
|
# "Authentication failure" (lib/modules/platform/gitea/index.ts initPlatform swallows the
|
|
# HTTP error), and a later scope gap surfaces as "Repository has unknown error". Two
|
|
# dispatches were spent learning one missing scope at a time. These probes are GETs only,
|
|
# and they name the missing scope. The token never reaches argv: curl reads it from stdin
|
|
# (`--config -`), so it is not in `ps` output or in the log.
|
|
run: |
|
|
set -uo pipefail
|
|
GITEA=https://gitea.home.keithjackson.dev
|
|
REPO=Home-Network/home-network-k3s-impl
|
|
fail=0
|
|
body=$(mktemp)
|
|
probe() { # <scope it proves> <path> <auth: bearer|basic>
|
|
local code
|
|
if [ "$3" = basic ]; then
|
|
code=$(printf 'user = "renovate:%s"\n' "$RENOVATE_TOKEN" \
|
|
| curl -sS --config - -o "$body" -w '%{http_code}' "$GITEA$2") || code=000
|
|
else
|
|
code=$(printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \
|
|
| curl -sS --config - -o "$body" -w '%{http_code}' "$GITEA$2") || code=000
|
|
fi
|
|
if [ "$code" = 200 ]; then
|
|
echo "ok $1 (GET ${2%%\?*} -> 200)"
|
|
return
|
|
fi
|
|
fail=1
|
|
echo "::error::scope $1: GET ${2%%\?*} -> $code"
|
|
case "$code" in
|
|
401) echo " 401 = Gitea rejected the token VALUE (wrong, expired or revoked)." ;;
|
|
403) if grep -q 'Access denied' "$body"; then
|
|
echo " 403 'Access denied' = a Cilium/Envoy policy denial, not Gitea."
|
|
else
|
|
echo " Gitea: $(jq -r '.message // empty' "$body" 2>/dev/null | head -c 300)"
|
|
fi ;;
|
|
*) echo " body: $(head -c 200 "$body")" ;;
|
|
esac
|
|
}
|
|
probe read:user "/api/v1/user" bearer
|
|
probe read:organization "/api/v1/orgs/${REPO%%/*}" bearer
|
|
probe read:repository "/api/v1/repos/$REPO" bearer
|
|
probe read:issue "/api/v1/repos/$REPO/labels?limit=1" bearer
|
|
probe read:package "/v2/token?service=container_registry&scope=repository:home-network/toolbox:pull" basic
|
|
# WRITE scopes cannot be proven by a GET. Gitea's scope-refusal message, however, lists
|
|
# the token's scopes (names only, never the value): ask for an admin route this bot
|
|
# cannot have, and read `token scope=` from the 403. Advisory if the format changes.
|
|
printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \
|
|
| curl -sS --config - -o "$body" "$GITEA/api/v1/admin/orgs?limit=1" || true
|
|
scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p')
|
|
if [ -n "$scopes" ]; then
|
|
echo "token scopes (from Gitea): $scopes"
|
|
for want in write:repository write:issue; do
|
|
case ",$scopes," in
|
|
*",$want,"*|*",all,"*) echo "ok $want" ;;
|
|
*) echo "::error::scope $want is missing (token scope=$scopes)"; fail=1 ;;
|
|
esac
|
|
done
|
|
else
|
|
echo "::warning::could not read the token's scope list; write:repository and write:issue are UNVERIFIED"
|
|
fi
|
|
rm -f "$body"
|
|
if [ "$fail" -ne 0 ]; then
|
|
echo "::error::RENOVATE_TOKEN failed the checks above (a 401 is the value, a 403 is a scope). Re-mint it for renovate-bot with read:user, read:organization, write:repository, write:issue, read:package and overwrite the secret."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Run Renovate
|
|
shell: bash
|
|
# The repo config is discovered by Renovate from the cloned repo (renovate.json5,
|
|
# RENOVATE_REQUIRE_CONFIG=required). The action used to ALSO load it as the global config
|
|
# via RENOVATE_CONFIG_FILE; that double-loading is gone. ci/lint.sh's `configurationFile:`
|
|
# check therefore has nothing to resolve in this file, and passes.
|
|
env:
|
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
|
# Self-hosted Gitea. `platform` + `endpoint` were the two values renovate.json's own
|
|
# header said were "set at runtime by the operator's renovate runner" — this is that
|
|
# runner, so they are set here rather than in the repo config, which keeps renovate.json
|
|
# portable and free of this instance's URL.
|
|
RENOVATE_PLATFORM: gitea
|
|
RENOVATE_ENDPOINT: https://gitea.home.keithjackson.dev/api/v1
|
|
RENOVATE_REPOSITORIES: Home-Network/home-network-k3s-impl
|
|
# Renovate must never push to a protected branch; it works on its own branches and opens
|
|
# PRs against this base. Stated explicitly rather than inherited, because this is the
|
|
# property that keeps the whole mechanism inside CLAUDE.md §1's branch -> PR -> gate path.
|
|
RENOVATE_BASE_BRANCHES: main
|
|
RENOVATE_ONBOARDING: "false"
|
|
RENOVATE_REQUIRE_CONFIG: "required"
|
|
RENOVATE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true' && 'full' || '' }}
|
|
LOG_LEVEL: ${{ github.event_name == 'workflow_dispatch' && inputs.log_level || 'info' }}
|
|
# ADDED 2026-10-29: registry read access for this repo's OWN images (toolbox,
|
|
# agent-runner, conformance-runner — published by .gitea/workflows/images.yaml and
|
|
# followed through `# renovate-digest:` markers). The Gitea registry answers 401 to
|
|
# anonymous reads, and Renovate scopes the platform token to hostType `gitea`, which a
|
|
# docker lookup never matches — so without this rule every digest lookup fails. Same
|
|
# token; it therefore needs scope `read:package` in addition to the ones listed in the
|
|
# preflight above. Gitea ignores the username for token auth (services/auth/basic.go).
|
|
RENOVATE_HOST_RULES: '[{"hostType":"docker","matchHost":"gitea.home.keithjackson.dev","username":"renovate","password":"${{ secrets.RENOVATE_TOKEN }}"}]'
|
|
# OPTIONAL, ADDED 2026-10-30. A github.com token (a fine-grained PAT with NO permissions,
|
|
# public-repo read only) for lookups of GitHub-hosted dependencies. Without it Renovate
|
|
# SKIPS them with `github-token-required` — measured in a local dry run: 17 deps, i.e.
|
|
# every `uses: actions/*` here plus kubevirt/kubevirt, kubevirt/containerized-data-importer
|
|
# and intel/intel-device-plugins-for-kubernetes. Unset is safe: the step below drops the
|
|
# empty variable, and those deps simply stay unmaintained, with a WARN in the log.
|
|
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
# An expression that evaluates to '' still EXPORTS the variable, as an empty string.
|
|
# Drop those so an unset value means "Renovate's default", never "set to empty".
|
|
for v in RENOVATE_DRY_RUN GITHUB_COM_TOKEN; do
|
|
[ -n "${!v:-}" ] || unset "$v"
|
|
done
|
|
# Through the image's entrypoint, as `docker run` would have gone: it sources the
|
|
# containerbase env and initialises the bundled tools before exec'ing Renovate.
|
|
exec renovate-entrypoint.sh renovate
|
|
|
|
- name: Summary
|
|
if: always()
|
|
run: |
|
|
{
|
|
echo "## Renovate"
|
|
echo
|
|
echo "- trigger: \`${{ github.event_name }}\`"
|
|
echo "- dry run: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}\`"
|
|
echo "- outcome: \`${{ job.status }}\`"
|
|
echo
|
|
echo "Renovate owns BOTH Helm chart bumps and container image tags here; Flux"
|
|
echo "image-automation is deliberately disabled (platform/kustomization.yaml)."
|
|
echo "Every change it proposes arrives as a pull request and must pass the merge gate."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|