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>
218 lines
12 KiB
YAML
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
|