gabe.labrats.cc upkeep

0 5 pulls 1 Updated 22h
docker pull atcr.io/gabe.labrats.cc/upkeep:0.1.0
Image Size Layers
167.9 MB 99
Pushed 22h
Vulnerabilities
Loading...
Pulls
5 total
Last pull 22h

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.

  1. Account + app password.
  2. SSH public key as a sh.tangled.publicKey record.
  3. Add it as a repo collaborator. Without this, spindle silently declines to run
    pull-request pipelines — isPullTriggerAuthorized returns 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.repo is the repo’s own DID — not the owner’s, not owner/name. The
    appview parses it with syntax.ParseDID. Read repoDid off the sh.tangled.repo
    record.
  • Pipelines are not atproto records. sh.tangled.pipeline.* on the appview returns
    {"items":[]} forever; the live surface is spindle’s sh.tangled.ci.*.
  • trigger.pull is 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>, not sh.tangled.owner — that
    returns the human who runs the knot, and yields 401 audience mismatch.
  • The knot writes no record on merge. It performs the git operation and returns
    200; without MarkMerged the pull reads open forever.
  • Patches are generated locally with git format-patch, avoiding the knot’s
    binary_omitted truncation and any window before it sees the pushed branch.
  • The appview drops malformed records silently. A successful write proves nothing;
    record shapes come from tangled.org/core’s generated lexicon types.
  • Lockfile and artifact updates are unsupportedupdateArtifacts runs in the
    branch worker, which --platform=local skips.