gabe.labrats.cc upkeep
docker pull atcr.io/gabe.labrats.cc/upkeep:0.1.0
upkeep
Renovate’s dependency updates, published as pull requests on Tangled.
Renovate has no Tangled platform and no plugin API. upkeep uses it as a lookup
engine and does the publishing itself.
initContainer git clone → /work/repo
initContainer renovate --platform=local → /work/report.json
--report-type=file
container upkeep --report … --repo …
--platform=local pins dryRun to lookup: Renovate resolves every dependency and
candidate update, then stops without touching files or git. Your existing
renovate.json — managers, datasources, versioning, packageRules — keeps working.
A run
parse report.json → updates drop pendingChecks, missing replaceString
group by Renovate's branchName related bumps ship together
classify against the bot's own records open / merged / closed × same / different
edit replaceString swap in a worktree fail closed on anything ambiguous
publish commit → push → format-patch gzip → uploadBlob → createRecord
automerge poll CI on the pushed sha green → mergeCheck → merge → mark merged
Up to --max-per-run groups per run; the rest are logged as deferred.
Grouping is a correctness requirement
renovate/kubernetes → registry.k8s.io/kube-apiserver v1.36.2 → v1.36.4
registry.k8s.io/kube-controller-manager v1.36.2 → v1.36.4
registry.k8s.io/kube-scheduler v1.36.2 → v1.36.4
ghcr.io/siderolabs/kubelet v1.36.2 → v1.36.4
…across 4 files, 2 clusters
Shipping those separately leaves a control plane ahead of its kubelets.
Editing: literal swaps, or nothing
Renovate hands over the exact substring it would rewrite. Applying an update is a
string swap, not YAML surgery.
replaceString → result
0.58.0 → 0.58.1
ghcr.io/cloudnative-pg/postgresql:18.6-minimal → …:18.7-minimal
v1.95.0@sha256:af7ff044… → v1.97.0@sha256:4b3226f4… tag AND digest
A refusal skips the whole group; a partially applied group is worse than none.
| refused when | why |
|---|---|
replaceString occurs 0 or ≥2 times |
ambiguous target |
digest pinned, no newDigest supplied |
tag and digest would disagree |
@sha256: present but untracked |
same, and we cannot fix it |
| two updates disagree on one substring | contradictory |
| edits produce no change | already applied |
Deduplication
The key is a fingerprint of (branch, target versions), written into the body as
Upkeep-Id:. Not the branch — Renovate’s names are range- or group-scoped
(renovate/kubernetes, …busybox-1.x) and stay stable across releases, so keying on
them would stop updating a dependency after its first merge.
| prior pull | same proposal | different proposal |
|---|---|---|
| open | already proposed → automerge candidate | stale → close it, open the new one |
| merged / closed | settled → leave it | history → ignore |
State comes from sh.tangled.repo.pull.listStatuses, which sees records authored by
anyone — a merge or close you performed by hand lives on your PDS, not the bot’s.
Pulls are also filtered to target.repo == <this repo>; branch names are
repository-independent, so without that a run could close another repo’s pull.
Automerge
Off unless UPKEEP_WAIT_TIMEOUT is set.
The decision comes from your own packageRules. Renovate resolves automerge
internally but never serialises it — it lands on a BranchConfig, and under
--platform=local the report carries only packageFiles and the repo-cache write is
skipped. So upkeep reads .renovaterc.json5 and reproduces the resolution.
matchManagers matchDatasources matchPackageNames
matchUpdateTypes matchCurrentVersion matchFileNames
/expr/ regex /expr/i case-insensitive !/expr/ negated
* everything otherwise glob, case-insensitive
positive patterns OR · every negative must hold · last matching rule wins
Fail closed. A rule setting automerge: true that uses schedule,
matchConfidence, matchCurrentAge, or any unimplemented matcher turns automerge
off for what it matches, and is reported at startup. Divergence from Renovate costs
a manual merge, never a wrong one.
A group merges only if every dependency in it is eligible.
Waiting
ci.queryPipelines(repo, kinds=pull_request, commits=<pushed sha>)
all success → mergeCheck → merge → mark merged
failed/timeout/cancelled → leave open, stop waiting
pending/running/absent → keep polling
deadline reached → defer to the next run, exit 0
Keyed on the commit, not the branch: a force-pushed branch can still have a green
pipeline from its previous head.
Configuration
| flag | default | |
|---|---|---|
--report |
report.json |
Renovate’s report |
--repo |
. |
the clone Renovate ran against |
--max-per-run |
3 |
pulls opened per run |
--dry-run |
false |
plan and commit locally, publish nothing |
| variable | default | |
|---|---|---|
UPKEEP_REPO_DID |
— | required; target.repo, must be a DID |
UPKEEP_HANDLE / _APP_PASSWORD |
— | required; the bot account |
UPKEEP_PDS_HOST |
https://bsky.social |
|
UPKEEP_BASE_BRANCH / _REMOTE |
main / origin |
|
UPKEEP_COMMIT_NAME / _EMAIL |
the authenticated account | never a placeholder |
UPKEEP_WAIT_TIMEOUT |
0 |
0 disables automerge |
UPKEEP_POLL_INTERVAL |
30s |
|
UPKEEP_KNOT_HOST / _SPINDLE_HOST |
— | required for automerge |
UPKEEP_APPVIEW_ENDPOINT |
https://api.tangled.org |
pull state |
UPKEEP_RENOVATE_CONFIG |
auto-discovered | |
UPKEEP_LOG_LEVEL |
info |
--dry-run needs none of the required variables — no auth, no network — but still
commits to local branches, so git diff main..renovate/<branch> shows the result.
Bot setup
The bot needs its own atproto account; a pull can only be managed by the identity that
opened it.
- Account + app password.
- SSH public key as a
sh.tangled.publicKeyrecord. - Add it as a repo collaborator. Without this, spindle silently declines to run
pull-request pipelines —isPullTriggerAuthorizedreturns nil with no diagnostic.
Development
nix develop # go, renovate, git, staticcheck
go test ./...
nix build .#upkeep
internal/upkeep/testdata/report.json is a trimmed real report from a Flux GitOps
repo, so the parser and edit planner run against actual manager output.
Protocol notes
Each of these was found the hard way.
target.repois the repo’s own DID — not the owner’s, notowner/name. The
appview parses it withsyntax.ParseDID. ReadrepoDidoff thesh.tangled.repo
record.- Pipelines are not atproto records.
sh.tangled.pipeline.*on the appview returns
{"items":[]}forever; the live surface is spindle’ssh.tangled.ci.*. trigger.pullis never populated, so CI correlates by commit sha.- Only branch-based pulls get pipelines.
source.repo != nil || source == nil→
ignored. A patch-only pull would be invisible to CI. - Service-auth audience is
did:web:<knot host>, notsh.tangled.owner— that
returns the human who runs the knot, and yields401 audience mismatch. - The knot writes no record on merge. It performs the git operation and returns
200; withoutMarkMergedthe pull reads open forever. - Patches are generated locally with
git format-patch, avoiding the knot’s
binary_omittedtruncation and any window before it sees the pushed branch. - The appview drops malformed records silently. A successful write proves nothing;
record shapes come fromtangled.org/core’s generated lexicon types. - Lockfile and artifact updates are unsupported —
updateArtifactsruns in the
branch worker, which--platform=localskips.