diff --git a/.woodpecker/build-ci-images.yml b/.woodpecker/build-ci-images.yml index 4d33f4dd..ed2cde89 100644 --- a/.woodpecker/build-ci-images.yml +++ b/.woodpecker/build-ci-images.yml @@ -100,29 +100,10 @@ steps: --destination gitea.renner.uno/gitea_admin/ci-tofu:$TAG \ --destination gitea.renner.uno/gitea_admin/ci-tofu:latest - - name: build-claude - image: gcr.io/kaniko-project/executor:v1.23.2-debug - directory: ci/images/claude - depends_on: [] - when: - - event: manual - - event: push - path: ["ci/images/claude/**", ".woodpecker/build-ci-images.yml"] - environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - 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 - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/ci-claude:$TAG \ - --destination gitea.renner.uno/gitea_admin/ci-claude:latest + # NOTE: ci-claude is deliberately NOT built here. It tracks upstream Claude + # Code releases on its own cadence and is built + version-tagged by + # ci-claude-refresh.yml, which also opens the PR that rolls a new version out + # to the consumer pipelines. Building it here too would fight over tags. # Runtime image, not CI tooling: a static musl binary in a FROM-scratch image, # used as the base for secret-rotation CronJobs. Replaces per-job inline Python. diff --git a/.woodpecker/ci-claude-refresh.yml b/.woodpecker/ci-claude-refresh.yml new file mode 100644 index 00000000..2e841369 --- /dev/null +++ b/.woodpecker/ci-claude-refresh.yml @@ -0,0 +1,203 @@ +# 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: +# 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 the version actually changed) rewrite the 5 +# consumer pins to ci-claude: and open a PR. A human +# reviews and merges it; nothing auto-merges. +# +# TAG SCHEME — deliberately version tags (ci-claude:2.1.218), 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 dedicated, least-privilege identities, per the #330 rule that +# every pipeline gets its own scoped bot and never the admin gitea_token: +# - ci_images_token : package read+write ONLY (the kaniko push). Shared +# with build-ci-images.yml — same job, same scope. +# - ci_claude_bump_bot_token : write:repository ONLY, owned by a dedicated +# ci-claude-bump-bot identity. It pushes a branch and +# opens a PR; it does NOT comment on issues, so it +# needs no write:issue. It must be a write +# collaborator on k.renner/homelab. +# +# OWNER SETUP (one-time — this pipeline is inert until all three are done): +# 1. Create the ci-claude-bump-bot Gitea user, add it as a write +# collaborator on k.renner/homelab, mint a PAT scoped write:repository +# only, and store it as the Woodpecker secret ci_claude_bump_bot_token. +# 2. Confirm the existing ci_images_token secret is available to this repo. +# 3. Register the cron (weekly is plenty; Claude Code ships often but the +# pipelines tolerate being a few days behind): +# woodpecker repo cron add --repo k.renner/homelab \ +# --name ci-claude-refresh --branch master --schedule '@weekly' +# +# Triggering: +# - cron : the cron NAMED exactly "ci-claude-refresh" (see above). +# - manual : woodpecker pipeline create --branch master \ +# --var PIPELINE=ci-claude-refresh k.renner/homelab +# - push : a change to the Containerfile (or this file) forces a rebuild of +# the current latest version so Containerfile fixes ship promptly. +when: + - event: cron + cron: ci-claude-refresh + - event: manual + evaluate: 'PIPELINE == "ci-claude-refresh"' + - event: push + 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. Keep this list in sync with the consumers. + CONSUMERS=" + .woodpecker/security-scan.yml + .woodpecker/alert-monitor.yml + .woodpecker/update-audit.yml + .woodpecker/pr-review.yml + .woodpecker/issue-fix.yml + " + 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 + # The tag every consumer currently references. They are kept identical, + # so this must resolve to EXACTLY ONE non-empty tag; anything else + # (no match, or a partial/disagreeing set) aborts rather than risk a + # corrupting sed later. Counting with `grep -c .` (non-empty lines) + # instead of `wc -l` is deliberate: an empty result yields 0 here but + # 1 under wc -l, which would slip past the guard. + CUR=$$(grep -hoE 'ci-claude:[0-9A-Za-z._-]+' $$CONSUMERS | sed 's/^ci-claude://' | sort -u) + NTAGS=$$(printf '%s' "$$CUR" | grep -c . || true) + if [ -z "$$CUR" ] || [ "$$NTAGS" -ne 1 ]; then + echo "Expected exactly one ci-claude tag across the consumers, got [$$CUR]." + echo "Refusing to guess — fix the pins by hand."; exit 1 + fi + 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 the version changed, or on a push (Containerfile edit must + # ship even if the version is unchanged). Bump the pins only when the + # version the consumers reference is not the 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: gcr.io/kaniko-project/executor:v1.23.2-debug + 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 + 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/k.renner/homelab" + BRANCH="ci-image/claude-$$NEW" + if git ls-remote --heads "https://gitea.renner.uno/k.renner/homelab.git" "$$BRANCH" | grep -q .; then + echo "Branch $$BRANCH already exists — a PR for $$NEW is already open. Nothing to do." + exit 0 + fi + CONSUMERS=" + .woodpecker/security-scan.yml + .woodpecker/alert-monitor.yml + .woodpecker/update-audit.yml + .woodpecker/pr-review.yml + .woodpecker/issue-fix.yml + " + # Exact-string swap of the current tag for the new one, consumers only. + for f in $$CONSUMERS; do + sed -i "s#ci-claude:$$CUR#ci-claude:$$NEW#g" "$$f" + done + git config user.email "ci-claude-bump-bot@renner.uno" + git config user.name "CI Claude Bump" + 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/k.renner/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 5 pipelines that\n' + 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: "master"}' > /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 diff --git a/ci/images/claude/Containerfile b/ci/images/claude/Containerfile index 0b2edcb2..0700cd43 100644 --- a/ci/images/claude/Containerfile +++ b/ci/images/claude/Containerfile @@ -1,6 +1,10 @@ FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 -ARG CLAUDE_CODE_VERSION=2.1.209 +# Default to the latest Claude Code. Pinning a fixed version is the wrong +# default here: old releases eventually stop working against the API, so a +# stale pin silently rots the agent pipelines. Override only to dodge a known +# broken release, e.g. --build-arg CLAUDE_CODE_VERSION=2.1.217 +ARG CLAUDE_CODE_VERSION=latest RUN apk add --no-cache \ bash \ @@ -11,5 +15,32 @@ RUN apk add --no-cache \ openssh-client \ openssl +# Install Claude Code, then make the resulting layer byte-stable so that a +# rebuild of the SAME version DEDUPS in the Gitea registry instead of orphaning +# a fresh ~260 MB copy on every build (#351, follow-up from #336). npm picks the +# musl-linked native binary automatically on this alpine base. +# +# Two sources of per-build churn are eliminated inside this one layer: +# 1. the npm cache under /root/.npm — its index/log files carry build-time +# content and timestamps, and it never belongs in the image anyway. +# 2. install-time mtimes on every installed file and on the directories this +# RUN modified — normalized to a fixed date. Without this the tar bytes +# (and thus the layer digest) differ each build even when the version is +# unchanged, defeating registry dedup. +# Content itself is deterministic per version: the registry tarballs are +# immutable, so same version in -> same bytes out once timestamps are pinned. RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \ - && claude --version + && claude --version \ + && npm cache clean --force \ + && rm -rf /root/.npm /tmp/* \ + && find /usr/local/lib/node_modules/@anthropic-ai -exec touch -h -t 202001010000.00 {} + \ + && touch -h -t 202001010000.00 \ + /usr/local/bin/claude \ + /usr/local/lib/node_modules/@anthropic-ai \ + /usr/local/lib/node_modules \ + /usr/local/lib \ + /usr/local/bin \ + /usr/local \ + /usr \ + /root \ + /tmp