Files
homelab/.woodpecker/ci-claude-refresh.yml
claude-botandClaude Opus 5.5 5528ccbd0b
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
chore: default branch main
Pipeline branch filters and PR bases, Flux GitRepository ref, image automation
checkout and push, restore-test, vmtest and bootstrap.sh. Merge right before
the master -> main rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:57:20 +02:00

218 lines
12 KiB
YAML

# ci-claude refresh — keep the CI agent image on the LATEST Claude Code and
# roll that version out to every pipeline that runs it, automatically.
#
# Why this exists (#351, follow-up from #336): the ci-claude image used to pin
# a fixed Claude Code version in its Containerfile, and every consumer pipeline
# pinned a stale dated image tag. Old Claude Code releases eventually stop
# working against the API, so both pins silently rot the agent pipelines. This
# pipeline owns the whole lifecycle instead:
#
# 1. decide — resolve the latest published Claude Code version and compare
# it to the tag the consumer pipelines currently reference.
# 2. build — (only when a rebuild is warranted) kaniko-build the image
# pinned to that exact version and push ci-claude:<version>
# and ci-claude:latest. The Containerfile makes this layer
# byte-reproducible, so rebuilding an unchanged version dedups
# in the registry instead of orphaning a fresh ~260 MB copy.
# 3. bump-pr — (only when any pin is off the latest version) rewrite every
# consumer pin to ci-claude:<version> and open a PR. A human
# reviews and merges it; nothing auto-merges.
#
# TAG SCHEME — deliberately version tags (ci-claude:<claude-version>), NOT the dated
# tags the other ci-* images use. The tag encodes the thing that changes here
# (the Claude version), which makes "did it change?" a trivial, race-free
# string compare and keeps the weekly cron from opening no-op PRs. The other
# images change only when their Containerfile changes, so dated tags fit them;
# this one tracks an upstream release cadence.
#
# This pipeline OWNS building ci-claude: the image's build step was removed
# from build-ci-images.yml so the two never fight over tags.
#
# TOKENS — two least-privilege, single-purpose secrets, per the #330 rule that
# a pipeline never gets a broader credential than it needs and never the admin
# gitea_token:
# - ci_images_token : package read+write ONLY (the kaniko push).
# Shared with build-ci-images.yml — same job.
# - ci_claude_bump_bot_token : a claude-bot PAT scoped write:repository ONLY
# (verified: 200 on the repo, 403 on
# /admin/users). It pushes the bump branch and
# opens the PR; it does NOT comment on issues, so
# it carries no write:issue. A dedicated Gitea
# *user* would need admin to create, which
# claude-bot lacks; a scoped-down claude-bot PAT
# gives the same least-privilege at the token
# level. claude-bot is already a write
# collaborator here. Secret scoped to the
# push/cron/manual events this pipeline fires on.
#
# OWNER SETUP — the token + secret above are already provisioned. The one
# remaining step, to be run AFTER this merges to main (a cron references a
# pipeline that must exist on the target branch), is to register the schedule:
# woodpecker repo cron add --repo konairius/homelab \
# --name ci-claude-refresh --branch main --schedule '@weekly'
# Weekly is plenty; the pipelines tolerate being a few days behind a release.
#
# Triggering:
# - cron : the cron NAMED exactly "ci-claude-refresh" (see above).
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> ci-claude-refresh
# where <n> is an existing pipeline to promote.
# - push : a change to the Containerfile (or this file) ON main forces a
# rebuild of the current latest version so Containerfile fixes ship
# promptly. Restricted to main ON PURPOSE — a feature branch must
# never build/push an image or open a bump PR (only main pushes
# images), and without this filter every PR that edits these paths
# would do exactly that from its own branch.
when:
- event: cron
cron: ci-claude-refresh
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "ci-claude-refresh"'
- event: push
branch: main
path:
- "ci/images/claude/**"
- ".woodpecker/ci-claude-refresh.yml"
steps:
# 1. Resolve latest vs. currently-pinned, and decide what to do. Writes
# sentinels to $CI_WORKSPACE/.ci-refresh/ (the workspace volume is the
# only path shared across steps; /tmp is per-container). Uses the ci-claude
# image purely as a toolbox (it ships npm, jq, git, curl).
- name: decide
image: gitea.renner.uno/gitea_admin/ci-claude:latest
commands:
- |
set -eu
mkdir -p "$$CI_WORKSPACE/.ci-refresh"
# Files that pin the image, DISCOVERED rather than listed. This used to
# be a hardcoded list carrying the instruction "keep this in sync with
# the consumers" — and it was not: e2e.yml and ci-scripts-test.yml were
# missing, so they sat on a version no bump could ever reach. A list
# that must be maintained by hand is the bug; deriving it removes the
# failure mode instead of correcting one instance of it.
#
# Matches a version-pinned reference only, which is what excludes this
# file: its own steps run ci-claude:latest, and :latest never needs
# rewriting. That exclusion is load-bearing — do not write a literal
# ci-claude version anywhere in THIS file, not even in prose, or it
# adds itself to the consumer set and the sed below rewrites it. The
# tag-scheme note at the top says <claude-version> for that reason.
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
# An empty list would make the grep below read stdin and hang forever
# rather than fail, so it is a hard stop.
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
echo "consumers: $$CONSUMERS"
NEW=$$(npm view @anthropic-ai/claude-code version)
case "$$NEW" in
[0-9]*.[0-9]*.[0-9]*) : ;;
*) echo "npm returned an implausible version '$$NEW' — aborting."; exit 1 ;;
esac
# Distinct tags the consumers currently reference. Divergent pins are
# normal (a pipeline bumped on its own); the bump rewrites all of them.
CUR=$$(grep -hoE 'ci-claude:[0-9][0-9A-Za-z._-]*' $$CONSUMERS | sed 's/^ci-claude://' | sort -u | tr '\n' ' ' | sed 's/ $$//')
[ -n "$$CUR" ] || { echo "No ci-claude pin found in the consumers — aborting."; exit 1; }
echo "latest=$$NEW current=[$$CUR] event=$$CI_PIPELINE_EVENT"
printf '%s' "$$NEW" > "$$CI_WORKSPACE/.ci-refresh/version"
printf '%s' "$$CUR" > "$$CI_WORKSPACE/.ci-refresh/current"
# Build when any pin is off latest, or on a push (Containerfile edit must
# ship even if the version is unchanged). Bump when any pin is off latest.
if [ "$$CUR" != "$$NEW" ] || [ "$$CI_PIPELINE_EVENT" = "push" ]; then
touch "$$CI_WORKSPACE/.ci-refresh/do-build"; echo "will build $$NEW"
else
echo "already on $$NEW — nothing to build"
fi
if [ "$$CUR" != "$$NEW" ]; then
touch "$$CI_WORKSPACE/.ci-refresh/do-bump"; echo "will open bump PR [$$CUR] -> $$NEW"
else
echo "consumers already pin $$NEW — no PR"
fi
# 2. Build the exact resolved version and push version + latest tags. Pinning
# --build-arg to the resolved version (not "latest") guarantees the tag and
# the image content agree even if a new release lands mid-run.
- name: build
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/claude
depends_on: [decide]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- |
[ -f "$$CI_WORKSPACE/.ci-refresh/do-build" ] || { echo "no build needed"; exit 0; }
set -eu
VERSION=$$(cat "$$CI_WORKSPACE/.ci-refresh/version")
mkdir -p /kaniko/.docker
AUTH=$$(printf '%s:%s' "$$GITEA_USER" "$$GITEA_TOKEN" | base64 | tr -d '\n')
printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$$AUTH" > /kaniko/.docker/config.json
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--build-arg CLAUDE_CODE_VERSION="$$VERSION" \
--destination gitea.renner.uno/gitea_admin/ci-claude:"$$VERSION" \
--destination gitea.renner.uno/gitea_admin/ci-claude:latest
# 3. Rewrite the consumer pins and open a PR. Never auto-merges. Skips itself
# when the version was unchanged, and when a bump branch already exists (a
# prior run's PR is still open / being merged).
- name: bump-pr
image: gitea.renner.uno/gitea_admin/ci-claude:latest
depends_on: [build]
environment:
PUSH_TOKEN:
from_secret: ci_claude_bump_bot_token
commands:
- |
[ -f "$$CI_WORKSPACE/.ci-refresh/do-bump" ] || { echo "no version change — no PR"; exit 0; }
set -eu
# Secret values can carry a trailing newline; it breaks the push URL and the auth header.
PUSH_TOKEN=$$(printf '%s' "$$PUSH_TOKEN" | tr -d '\r\n')
[ -n "$$PUSH_TOKEN" ] || { echo "PUSH_TOKEN is empty — aborting"; exit 1; }
NEW=$$(cat "$$CI_WORKSPACE/.ci-refresh/version")
CUR=$$(cat "$$CI_WORKSPACE/.ci-refresh/current")
# Both were validated non-empty by the decide step; re-assert before a
# sed that would corrupt every pin if either were blank.
[ -n "$$NEW" ] && [ -n "$$CUR" ] || { echo "empty version/current sentinel — aborting"; exit 1; }
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
BRANCH="ci-image/claude-$$NEW"
if git ls-remote --heads "https://gitea.renner.uno/konairius/homelab.git" "$$BRANCH" | grep -q .; then
echo "Branch $$BRANCH already exists — a PR for $$NEW is already open. Nothing to do."
exit 0
fi
# Same discovery as the decide step — see the comment there for why this
# is not a maintained list.
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
# NEW goes into a sed replacement: allow only a plain x.y.z version.
printf '%s' "$$NEW" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "refusing non-x.y.z version '$$NEW'"; exit 1; }
# Every version-pinned tag becomes NEW, whatever it was; consumers only.
for f in $$CONSUMERS; do
sed -i "s#ci-claude:[0-9][0-9A-Za-z._-]*#ci-claude:$$NEW#g" "$$f"
done
# Author is claude-bot (the PAT's account); the commit subject makes
# the automated-bump nature clear.
git config user.email "claude-bot@renner.uno"
git config user.name "Claude Bot"
git checkout -b "$$BRANCH"
git add $$CONSUMERS
git commit -m "chore(ci): bump ci-claude image to $$NEW"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
{
printf 'Automated bump of the ci-claude agent image from `%s` to `%s`.\n\n' "$$CUR" "$$NEW"
printf 'The refresh pipeline built and pushed `ci-claude:%s` (and `:latest`) from the\n' "$$NEW"
printf 'latest published `@anthropic-ai/claude-code`. This rewires the %s pipelines that\n' "$$(printf '%s\n' $$CONSUMERS | grep -c .)"
printf 'run the image so CI stops running a stale Claude Code.\n\n'
printf 'Merging is a human decision — review, then merge to roll it out.\n'
} > /tmp/pr-body.md
jq -n --arg title "chore(ci): bump ci-claude image to $$NEW" \
--rawfile body /tmp/pr-body.md \
--arg head "$$BRANCH" \
'{title: $$title, body: $$body, head: $$head, base: "main"}' > /tmp/pr.json
code=$$(curl -s -o /tmp/resp.json -w '%{http_code}' \
-X POST -H "Authorization: token $$PUSH_TOKEN" \
-H "Content-Type: application/json" --data @/tmp/pr.json "$$API/pulls")
echo "POST pull -> HTTP $$code"
[ "$$code" -ge 200 ] && [ "$$code" -lt 300 ] || { cat /tmp/resp.json; exit 1; }
jq '{number, html_url, title}' /tmp/resp.json