ci/images/claude/Containerfile installed a pinned Claude Code version, and every consumer pipeline pinned a stale dated image tag (ci-claude:2026-05-01, ~3 months old). Two separate problems:
The install layer changed on every build (install-time mtimes + the npm cache baked into the layer), so it never deduped in the Gitea registry — the biggest orphan pattern behind #336. It's now ~260 MB per no-op rebuild, because current Claude Code ships as a large native binary rather than portable JS.
A frozen version silently rots. Old Claude Code releases eventually stop working against the API, so pinning 2.1.209 (and never bumping the consumer tag) is an active liability.
What this does
Containerfile — reproducible layer + track latest
Default to @latest. Verified on real node:22-alpine: npm auto-selects the claude-code-linux-x64-musl native binary and claude --version runs. (A CLAUDE_CODE_VERSION build-arg still lets you pin to dodge a bad release.)
Byte-stable install layer: npm cache clean + rm -rf /root/.npm, then normalize install-time mtimes on the installed tree and every directory the RUN touches (parent-dir mtimes — e.g. /usr — were the subtle part).
Verified: two independent docker build --no-cache runs produce a byte-for-byte identical npm layer (every tar entry stamped 2020-01-01, zero metadata diffs) — exactly what the registry needs to dedup.
ci-claude-refresh.yml (new) — keep it current, automatically
Weekly cron + manual + push-on-Containerfile-change. Resolves the latest published version, and only when it differs from the tag the consumers pin does it build ci-claude:<version> (+:latest) and open a PR rewiring the 5 consumer pipelines. Nothing auto-merges.
Version tags (ci-claude:2.1.218) instead of dated tags — deliberate deviation. Makes "did it change?" a race-free string compare, keeps the cron from opening no-op PRs, self-documents which Claude version each pipeline runs. (Say the word if you'd rather keep dated tags.)
build-ci-images.yml no longer builds ci-claude (the refresh pipeline owns it, so they don't fight over tags).
Credential — already provisioned
The bump credential (ci_claude_bump_bot_token) is minted and wired:
A claude-bot PAT scoped write:repository only — verified 200 on the repo, 403 on /admin/users. (A dedicated Gitea user needs admin to create, which claude-bot lacks; the scoped-down PAT is the same least-privilege at the token level.)
Stored as the Woodpecker secret ci_claude_bump_bot_token, scoped to the push/cron/manual events this pipeline fires on.
Bump commits/PRs are authored by claude-bot; the ci_images_token still does the kaniko push.
⚠️ One step left — after merge
A cron references a pipeline that must already exist on the target branch, so register it once this is on master:
Fixes #351.
## What was wrong
`ci/images/claude/Containerfile` installed a **pinned** Claude Code version, and every consumer pipeline pinned a stale **dated** image tag (`ci-claude:2026-05-01`, ~3 months old). Two separate problems:
1. **The install layer changed on every build** (install-time mtimes + the npm cache baked into the layer), so it never deduped in the Gitea registry — the biggest orphan pattern behind #336. It's now ~**260 MB** per no-op rebuild, because current Claude Code ships as a large native binary rather than portable JS.
2. **A frozen version silently rots.** Old Claude Code releases eventually stop working against the API, so pinning `2.1.209` (and never bumping the consumer tag) is an active liability.
## What this does
**`Containerfile` — reproducible layer + track latest**
- Default to `@latest`. Verified on real `node:22-alpine`: npm auto-selects the `claude-code-linux-x64-musl` native binary and `claude --version` runs. (A `CLAUDE_CODE_VERSION` build-arg still lets you pin to dodge a bad release.)
- Byte-stable install layer: `npm cache clean` + `rm -rf /root/.npm`, then normalize install-time mtimes on the installed tree **and every directory the RUN touches** (parent-dir mtimes — e.g. `/usr` — were the subtle part).
- **Verified:** two independent `docker build --no-cache` runs produce a **byte-for-byte identical** npm layer (every tar entry stamped 2020-01-01, zero metadata diffs) — exactly what the registry needs to dedup.
**`ci-claude-refresh.yml` (new) — keep it current, automatically**
- Weekly cron + manual + push-on-Containerfile-change. Resolves the latest published version, and **only when it differs** from the tag the consumers pin does it build `ci-claude:<version>` (+`:latest`) and open a PR rewiring the 5 consumer pipelines. Nothing auto-merges.
- **Version tags** (`ci-claude:2.1.218`) instead of dated tags — deliberate deviation. Makes "did it change?" a race-free string compare, keeps the cron from opening no-op PRs, self-documents which Claude version each pipeline runs. *(Say the word if you'd rather keep dated tags.)*
**`build-ci-images.yml`** no longer builds `ci-claude` (the refresh pipeline owns it, so they don't fight over tags).
## Credential — already provisioned
The bump credential (`ci_claude_bump_bot_token`) is minted and wired:
- A **`claude-bot` PAT scoped `write:repository` only** — verified 200 on the repo, 403 on `/admin/users`. (A dedicated Gitea *user* needs admin to create, which `claude-bot` lacks; the scoped-down PAT is the same least-privilege at the token level.)
- Stored as the Woodpecker secret `ci_claude_bump_bot_token`, scoped to the `push`/`cron`/`manual` events this pipeline fires on.
- Bump commits/PRs are authored by `claude-bot`; the `ci_images_token` still does the kaniko push.
## ⚠️ One step left — after merge
A cron references a pipeline that must already exist on the target branch, so register it once this is on `master`:
```
woodpecker repo cron add --repo k.renner/homelab \
--name ci-claude-refresh --branch master --schedule '@weekly'
```
On merge, the push trigger builds `ci-claude:2.1.218` and opens the first bump PR flipping the consumers `2026-05-01` → `2.1.218`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The ci-claude image installed a fixed, pinned Claude Code version and every
consumer pipeline pinned a stale dated image tag. Two problems:
- The npm install layer changed on every build (install-time mtimes + the
npm cache), so it never deduped in the Gitea registry — the single largest
orphan pattern behind #336 (~260 MB orphaned per no-op rebuild).
- A frozen version silently rots: old Claude Code releases eventually stop
working against the API.
Containerfile: default to @latest (npm auto-selects the musl native binary on
this alpine base) and make the install layer byte-stable — clean the npm cache
and normalize install-time mtimes on the installed tree and the directories the
RUN touches. Two independent --no-cache builds now produce a byte-identical npm
layer, so a rebuild of the same version dedups instead of orphaning a copy.
New ci-claude-refresh.yml owns the image lifecycle: weekly (and on-demand) it
resolves the latest published version, and only when it differs from what the
consumers pin does it build ci-claude:<version> (+:latest) and open a PR
rewiring the 5 consumer pipelines. Version tags make "did it change?" a
race-free string compare and keep the cron from opening no-op PRs. Nothing
auto-merges. build-ci-images.yml no longer builds ci-claude so the two never
fight over tags.
Requires owner setup before it does anything (documented in the pipeline
header): a dedicated ci-claude-bump-bot identity (write:repository only) as the
ci_claude_bump_bot_token secret, and a cron named "ci-claude-refresh".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖Automated review — model claude-sonnet-5 (complexity: normal), commit a675bf6cf0b67401be1dfec718a62bda0da9a7d4.
Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.
Summary
This PR (1) makes the ci-claude image's install layer byte-reproducible by normalizing npm-cache/mtime churn in ci/images/claude/Containerfile, defaulting CLAUDE_CODE_VERSION to latest, and (2) adds a new ci-claude-refresh.yml pipeline that resolves the latest published Claude Code version, rebuilds/pushes ci-claude:<version> only when warranted, and opens a bump PR to rewire the 5 consumer pipelines. build-ci-images.yml is trimmed to stop building ci-claude so the two pipelines don't race on the same tag. The new pipeline follows the repo's existing conventions closely (the $$-escaping, sentinel-file hand-off between steps, least-privilege token-per-pipeline pattern from #330, git ls-remote dedup guard mirrored from issue-fix.yml/security-scan.yml) and I did not find a concrete blocking defect. A few points below are worth a look before/soon after merge.
🟡 Suggestions
Byte-reproducibility claim is not fully verifiable from this diff alone.ci/images/claude/Containerfile:36-46 normalizes mtimes on @anthropic-ai/**, /usr/local/bin/claude, and a fixed list of parent directories, but a global npm install -g on npm ≥7 can also write a top-level /usr/local/lib/node_modules/.package-lock.json (outside the @anthropic-ai tree the find targets and not in the explicit touch list). If that file is present in this alpine/npm version, its mtime would still vary per build and defeat the layer-dedup goal the whole change is built around. The PR body states this was verified with two --no-cache builds producing byte-identical layers, which would already cover this — I just can't re-run docker build in this review environment to confirm independently. Worth double-checking that verification explicitly accounted for .package-lock.json (or that it doesn't exist for this install path).
The CONSUMERS list is duplicated verbatim in the decide step (.woodpecker/ci-claude-refresh.yml:87-93) and the bump-pr step (:178-184). The comment above decide already flags "keep this list in sync with the consumers" — since it's copy-pasted twice in the same file, it's easy for a future edit to update one and miss the other, silently causing bump-pr's sed loop to skip a consumer that decide's guard already validated. Sourcing it from one place (e.g. write it to the same .ci-refresh sentinel dir in decide and read it back in bump-pr) would remove the duplication risk. Not blocking since both lists match today.
First merge will very likely fail the bump-pr step if the owner-setup isn't done yet, exactly as the PR body anticipates: the push from this PR's own merge matches the path trigger (ci/images/claude/**, .woodpecker/ci-claude-refresh.yml), decide will see CUR=2026-05-01 != NEW=<latest> and set do-bump, and if ci_claude_bump_bot_token isn't provisioned yet the git push in bump-pr (.woodpecker/ci-claude-refresh.yml:186) will attempt an unauthenticated push and fail the step. This is called out in the PR description as an expected, non-blocking outcome (build already succeeded by then), so just flagging it's worth provisioning the token first to avoid a red pipeline on merge.
🟢 Nits
.woodpecker/ci-claude-refresh.yml:97 — the version-format guard [0-9]*.[0-9]*.[0-9]* is a shell glob, not a regex; it's loose enough to accept things like pre-release suffixes (2.1.218-beta.1). That's harmless given it only gates against "npm returned garbage," but worth knowing it isn't a strict semver check.
Tests
No automated tests exist for Woodpecker pipeline YAML in this repo (consistent with the rest of .woodpecker/), so this is unavoidably untested by CI until it actually runs. The Containerfile change is the one part the author says was independently verified (two --no-cache builds, byte-identical layer). The pipeline logic itself (decide/build/bump-pr step interplay, sentinel hand-off via $CI_WORKSPACE, the git ls-remote dedup check) has not been exercised end-to-end — it can't be until the owner completes the one-time setup (bot identity + token + cron registration) called out in the PR body. Recommend watching the first real run (triggered by this PR's own merge) closely rather than treating it as done once merged.
Recommendation
Looks reasonable — no blocking issues; comments above are optional.
🤖 **Automated review** — model `claude-sonnet-5` (complexity: normal), commit `a675bf6cf0b67401be1dfec718a62bda0da9a7d4`.
> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.
---
## Summary
This PR (1) makes the `ci-claude` image's install layer byte-reproducible by normalizing npm-cache/mtime churn in `ci/images/claude/Containerfile`, defaulting `CLAUDE_CODE_VERSION` to `latest`, and (2) adds a new `ci-claude-refresh.yml` pipeline that resolves the latest published Claude Code version, rebuilds/pushes `ci-claude:<version>` only when warranted, and opens a bump PR to rewire the 5 consumer pipelines. `build-ci-images.yml` is trimmed to stop building `ci-claude` so the two pipelines don't race on the same tag. The new pipeline follows the repo's existing conventions closely (the `$$`-escaping, sentinel-file hand-off between steps, least-privilege token-per-pipeline pattern from #330, `git ls-remote` dedup guard mirrored from `issue-fix.yml`/`security-scan.yml`) and I did not find a concrete blocking defect. A few points below are worth a look before/soon after merge.
## 🟡 Suggestions
- **Byte-reproducibility claim is not fully verifiable from this diff alone.** `ci/images/claude/Containerfile:36-46` normalizes mtimes on `@anthropic-ai/**`, `/usr/local/bin/claude`, and a fixed list of parent directories, but a global `npm install -g` on npm ≥7 can also write a top-level `/usr/local/lib/node_modules/.package-lock.json` (outside the `@anthropic-ai` tree the `find` targets and not in the explicit `touch` list). If that file is present in this alpine/npm version, its mtime would still vary per build and defeat the layer-dedup goal the whole change is built around. The PR body states this was verified with two `--no-cache` builds producing byte-identical layers, which would already cover this — I just can't re-run `docker build` in this review environment to confirm independently. Worth double-checking that verification explicitly accounted for `.package-lock.json` (or that it doesn't exist for this install path).
- **The `CONSUMERS` list is duplicated verbatim** in the `decide` step (`.woodpecker/ci-claude-refresh.yml:87-93`) and the `bump-pr` step (`:178-184`). The comment above `decide` already flags "keep this list in sync with the consumers" — since it's copy-pasted twice in the same file, it's easy for a future edit to update one and miss the other, silently causing `bump-pr`'s `sed` loop to skip a consumer that `decide`'s guard already validated. Sourcing it from one place (e.g. write it to the same `.ci-refresh` sentinel dir in `decide` and read it back in `bump-pr`) would remove the duplication risk. Not blocking since both lists match today.
- **First merge will very likely fail the `bump-pr` step if the owner-setup isn't done yet**, exactly as the PR body anticipates: the push from this PR's own merge matches the `path` trigger (`ci/images/claude/**`, `.woodpecker/ci-claude-refresh.yml`), `decide` will see `CUR=2026-05-01 != NEW=<latest>` and set `do-bump`, and if `ci_claude_bump_bot_token` isn't provisioned yet the `git push` in `bump-pr` (`.woodpecker/ci-claude-refresh.yml:186`) will attempt an unauthenticated push and fail the step. This is called out in the PR description as an expected, non-blocking outcome (build already succeeded by then), so just flagging it's worth provisioning the token first to avoid a red pipeline on merge.
## 🟢 Nits
- `.woodpecker/ci-claude-refresh.yml:97` — the version-format guard `[0-9]*.[0-9]*.[0-9]*` is a shell glob, not a regex; it's loose enough to accept things like pre-release suffixes (`2.1.218-beta.1`). That's harmless given it only gates against "npm returned garbage," but worth knowing it isn't a strict semver check.
## Tests
No automated tests exist for Woodpecker pipeline YAML in this repo (consistent with the rest of `.woodpecker/`), so this is unavoidably untested by CI until it actually runs. The Containerfile change is the one part the author says was independently verified (two `--no-cache` builds, byte-identical layer). The pipeline logic itself (`decide`/`build`/`bump-pr` step interplay, sentinel hand-off via `$CI_WORKSPACE`, the `git ls-remote` dedup check) has not been exercised end-to-end — it can't be until the owner completes the one-time setup (bot identity + token + cron registration) called out in the PR body. Recommend watching the first real run (triggered by this PR's own merge) closely rather than treating it as done once merged.
## Recommendation
Looks reasonable — no blocking issues; comments above are optional.
<!-- pr-review:sha=a675bf6cf0b67401be1dfec718a62bda0da9a7d4 -->
The bump credential is a claude-bot PAT scoped write:repository only (a
dedicated Gitea user needs admin to create, which claude-bot lacks; the
scoped-down PAT is the same least-privilege at the token level). Token minted
and stored as the ci_claude_bump_bot_token Woodpecker secret (events:
push/cron/manual). Commit author set to claude-bot accordingly. Only the cron
registration remains, to be done after this merges to master.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The push trigger matched any branch, so pushing this feature branch ran the
refresh pipeline from the branch: it built and pushed ci-claude:2.1.218 and
opened a bump PR (#379) prematurely. Restrict the push event to branch: master
so only master ever builds/pushes images or opens bump PRs; cron and manual
are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖Automated review — model claude-sonnet-5 (complexity: normal), commit 16eecf07e8d2440bc5f37f074814fa5cbf7fd7cb.
Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.
Summary
This PR (1) makes the ci-claude image build byte-reproducible by stripping npm-cache
churn and normalizing install-time mtimes, (2) switches its default Claude Code version
to latest, and (3) adds a new ci-claude-refresh.yml pipeline that resolves the
latest published version weekly, rebuilds/pushes a version-tagged image only when
warranted, and opens a human-reviewed PR to bump the 5 consumer pipelines. I checked the
new pipeline's shell logic, its Woodpecker variable/escaping conventions, and its
interaction with build-ci-images.yml and the 5 consumer files against the rest of the
repo, and did not find a concrete defect. No blocking issues.
🟡 Suggestions
ci-claude-refresh.yml:96-102 and :187-193 — the CONSUMERS file list is
duplicated verbatim in both the decide and bump-pr steps (with a comment in each
telling a human to keep it in sync). Since both steps already share state via $CI_WORKSPACE/.ci-refresh/, the decide step could write the list there once and bump-pr could read it back, so a future consumer addition only needs updating in one
place instead of two that must be kept manually consistent.
ci-claude-refresh.yml:169-172 — the git ls-remote --heads ... | grep -q .
branch-existence check and the later git push are not atomic: two overlapping runs
(e.g. a manual trigger firing while the weekly cron is still running) could both pass
the check and then race on the push/PR-open. Given this only runs on a weekly cron +
occasional manual/push trigger, the odds are low, but a cheap mitigation would be to
let the second git push/PR-create simply fail-and-log rather than assuming the check
fully prevents the race.
Reproducibility claim — the PR body states two independent docker build --no-cache runs produced byte-identical layers; I can't re-verify that claim from a
static diff review (would need to actually build), but the Containerfile:274-303
logic (cache purge + recursive mtime normalization of /usr/local/lib/node_modules/@anthropic-ai
and every touched parent dir up to /usr and /root) is coherent with what would be
required to achieve it, and matches the specific problem described (parent-dir mtimes).
🟢 Nits
Containerfile:293-294 — find .../@anthropic-ai -exec touch ... {} + already
touches the @anthropic-ai directory itself (find includes the starting path), so the
explicit touch -h -t ... /usr/local/lib/node_modules/@anthropic-ai a few lines down
is redundant. Harmless (touch is idempotent), just a duplicate no-op.
Tests
No automated tests exist for Woodpecker pipeline YAML anywhere else in this repo, so the
absence of any here isn't a regression versus repo norms. The author states the
reproducibility property was verified manually with two docker build --no-cache runs;
that verification isn't captured in CI, so a future edit to the Containerfile install
layer could silently reintroduce non-determinism with nothing to catch it. Not something
I'd block on given the repo's existing conventions, but worth knowing.
I specifically checked (all consistent with the rest of the repo, not just asserted):
$$VAR / $${VAR:-} escaping matches the convention used in pr-review.yml and
others (Woodpecker substitutes over the whole YAML including comments).
CI_PIPELINE_EVENT, CI_WORKSPACE, and the evaluate: 'PIPELINE == "..."' pattern
are all used elsewhere in this repo's pipelines the same way.
The 5 CONSUMERS paths (security-scan.yml, alert-monitor.yml, update-audit.yml, pr-review.yml, issue-fix.yml) each currently pin exactly ci-claude:2026-05-01 —
confirmed by grepping the checkout — so the "exactly one tag" guard in the decide
step will pass on the very first run.
The embedded-token git push "https://$$PUSH_TOKEN@..." pattern matches the existing
usage in issue-fix.yml:196 and security-scan.yml:119, so this isn't a new risk
pattern introduced by this PR.
build-ci-images.yml's removal of the build-claude step is clean — it was an
independent (depends_on: []) step and no other step referenced it.
The decide/bump-pr steps never invoke the claude CLI (they use the ci-claude
image purely as an npm/jq/git/curl toolbox), so PUSH_TOKEN is never exposed to an
LLM agent context — unlike pr-review.yml, no env -u isolation is needed here.
Recommendation
Looks reasonable — no blocking issues; comments above are optional.
🤖 **Automated review** — model `claude-sonnet-5` (complexity: normal), commit `16eecf07e8d2440bc5f37f074814fa5cbf7fd7cb`.
> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.
---
## Summary
This PR (1) makes the `ci-claude` image build byte-reproducible by stripping npm-cache
churn and normalizing install-time mtimes, (2) switches its default Claude Code version
to `latest`, and (3) adds a new `ci-claude-refresh.yml` pipeline that resolves the
latest published version weekly, rebuilds/pushes a version-tagged image only when
warranted, and opens a human-reviewed PR to bump the 5 consumer pipelines. I checked the
new pipeline's shell logic, its Woodpecker variable/escaping conventions, and its
interaction with `build-ci-images.yml` and the 5 consumer files against the rest of the
repo, and did not find a concrete defect. No blocking issues.
## 🟡 Suggestions
- **`ci-claude-refresh.yml:96-102` and `:187-193`** — the `CONSUMERS` file list is
duplicated verbatim in both the `decide` and `bump-pr` steps (with a comment in each
telling a human to keep it in sync). Since both steps already share state via
`$CI_WORKSPACE/.ci-refresh/`, the `decide` step could write the list there once and
`bump-pr` could read it back, so a future consumer addition only needs updating in one
place instead of two that must be kept manually consistent.
- **`ci-claude-refresh.yml:169-172`** — the `git ls-remote --heads ... | grep -q .`
branch-existence check and the later `git push` are not atomic: two overlapping runs
(e.g. a manual trigger firing while the weekly cron is still running) could both pass
the check and then race on the push/PR-open. Given this only runs on a weekly cron +
occasional manual/push trigger, the odds are low, but a cheap mitigation would be to
let the second `git push`/PR-create simply fail-and-log rather than assuming the check
fully prevents the race.
- **Reproducibility claim** — the PR body states two independent `docker build
--no-cache` runs produced byte-identical layers; I can't re-verify that claim from a
static diff review (would need to actually build), but the `Containerfile:274-303`
logic (cache purge + recursive mtime normalization of `/usr/local/lib/node_modules/@anthropic-ai`
and every touched parent dir up to `/usr` and `/root`) is coherent with what would be
required to achieve it, and matches the specific problem described (parent-dir mtimes).
## 🟢 Nits
- **`Containerfile:293-294`** — `find .../@anthropic-ai -exec touch ... {} +` already
touches the `@anthropic-ai` directory itself (find includes the starting path), so the
explicit `touch -h -t ... /usr/local/lib/node_modules/@anthropic-ai` a few lines down
is redundant. Harmless (touch is idempotent), just a duplicate no-op.
## Tests
No automated tests exist for Woodpecker pipeline YAML anywhere else in this repo, so the
absence of any here isn't a regression versus repo norms. The author states the
reproducibility property was verified manually with two `docker build --no-cache` runs;
that verification isn't captured in CI, so a future edit to the `Containerfile` install
layer could silently reintroduce non-determinism with nothing to catch it. Not something
I'd block on given the repo's existing conventions, but worth knowing.
I specifically checked (all consistent with the rest of the repo, not just asserted):
- `$$VAR` / `$${VAR:-}` escaping matches the convention used in `pr-review.yml` and
others (Woodpecker substitutes over the whole YAML including comments).
- `CI_PIPELINE_EVENT`, `CI_WORKSPACE`, and the `evaluate: 'PIPELINE == "..."'` pattern
are all used elsewhere in this repo's pipelines the same way.
- The 5 `CONSUMERS` paths (`security-scan.yml`, `alert-monitor.yml`, `update-audit.yml`,
`pr-review.yml`, `issue-fix.yml`) each currently pin exactly `ci-claude:2026-05-01` —
confirmed by grepping the checkout — so the "exactly one tag" guard in the `decide`
step will pass on the very first run.
- The embedded-token `git push "https://$$PUSH_TOKEN@..."` pattern matches the existing
usage in `issue-fix.yml:196` and `security-scan.yml:119`, so this isn't a new risk
pattern introduced by this PR.
- `build-ci-images.yml`'s removal of the `build-claude` step is clean — it was an
independent (`depends_on: []`) step and no other step referenced it.
- The `decide`/`bump-pr` steps never invoke the `claude` CLI (they use the `ci-claude`
image purely as an npm/jq/git/curl toolbox), so `PUSH_TOKEN` is never exposed to an
LLM agent context — unlike `pr-review.yml`, no `env -u` isolation is needed here.
## Recommendation
Looks reasonable — no blocking issues; comments above are optional.
<!-- pr-review:sha=16eecf07e8d2440bc5f37f074814fa5cbf7fd7cb -->
🤖Automated review — model claude-opus-4-8 (complexity: normal), commit f414f6e7bbe7634cc979f21dade387d257d715ee.
Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.
Summary
This PR makes the ci-claude image build reproducible (byte-stable npm layer so
registry dedup works) and moves it to tracking the latest Claude Code via a new ci-claude-refresh.yml pipeline that resolves the version, conditionally rebuilds,
and opens a bump PR rewiring the 5 consumer pipelines. The design is careful — the
decide/build/bump gating, the "exactly one tag" guard, and the version/content
agreement via --build-arg are all sound. No blocking defects found; the notes
below are hardening and robustness suggestions.
I verified the consumer list: exactly 5 files pin ci-claude:2026-05-01
(issue-fix.yml:65, security-scan.yml:45, alert-monitor.yml:104, pr-review.yml:63, update-audit.yml:33), matching both hardcoded CONSUMERS
lists. The refresh pipeline's own :latest toolbox references are correctly
excluded from bumping.
🟡 Suggestions
Manual trigger isn't branch-restricted, contradicting the stated invariant. ci-claude-refresh.yml:68-69 gates the manual event on PIPELINE == "ci-claude-refresh" with no branch: filter. The header comment (lines
104-108) asserts "only master pushes images" and "a feature branch must never
build/push an image or open a bump PR" — but that guarantee comes from the push filter's branch: master (line 70) alone. A manual run targeting a
feature branch (woodpecker pipeline create --branch <feature> --var PIPELINE=ci-claude-refresh) would build/push ci-claude:latest from that
branch's Containerfile and open a bump PR. It's gated by manual-trigger
permission (repo write), so not externally exploitable, but it does break the
documented invariant. Consider adding branch: master (or an evaluate branch
check) to the manual condition too.
A failed PR POST leaves an orphan branch that permanently blocks that
version's bump. In bump-pr (lines 178-181) the step exits early when $BRANCH already exists on the remote. If a prior run pushed the branch (line
200) but the subsequent curl POST failed (lines 202-206 → exit 1), the
branch exists with no PR. Every later run for the same version sees the branch
and skips, so the PR is never retried until someone deletes the branch or a new
Claude version appears (which changes the branch name). Consider, when the
branch already exists, checking whether an open PR exists for it and creating one
if not — rather than unconditionally treating "branch exists" as "PR is open."
The decide guard checks tag agreement, not tag presence. ci-claude-refresh.yml:106-114 computes distinct tags across the consumers and
aborts unless exactly one non-empty tag is found. If a consumer were renamed or
stopped pinning ci-claude: (so the CONSUMERS list drifts from reality), the
remaining files could still yield exactly one tag and pass the guard — silently
leaving the drifted file un-bumped. A cheap extra check that each listed file
actually contains a ci-claude: pin would catch drift.
🟢 Nits
The CONSUMERS list is duplicated verbatim in the decide (lines 97-103) and bump-pr (lines 182-188) steps. The decide comment already flags "keep this
list in sync with the consumers" — with two copies there are now three things to
keep in sync. Not worth a shared file, but worth being aware of.
Tests
No automated tests accompany this change, which is consistent with the rest of .woodpecker/ (pipeline YAML isn't unit-tested in this repo). The reproducibility
claim rests on the author's manual two-build byte-diff verification, described in
the PR body; I could not independently reproduce that here (no docker in this
read-only checkout). If practical, the two-build digest comparison is the one
check that would confirm the layer actually dedups before relying on the cron.
Recommendation
Looks reasonable — no blocking issues; comments above are optional.
🤖 **Automated review** — model `claude-opus-4-8` (complexity: normal), commit `f414f6e7bbe7634cc979f21dade387d257d715ee`.
> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.
---
## Summary
This PR makes the `ci-claude` image build reproducible (byte-stable npm layer so
registry dedup works) and moves it to tracking the *latest* Claude Code via a new
`ci-claude-refresh.yml` pipeline that resolves the version, conditionally rebuilds,
and opens a bump PR rewiring the 5 consumer pipelines. The design is careful — the
decide/build/bump gating, the "exactly one tag" guard, and the version/content
agreement via `--build-arg` are all sound. No blocking defects found; the notes
below are hardening and robustness suggestions.
I verified the consumer list: exactly 5 files pin `ci-claude:2026-05-01`
(`issue-fix.yml:65`, `security-scan.yml:45`, `alert-monitor.yml:104`,
`pr-review.yml:63`, `update-audit.yml:33`), matching both hardcoded `CONSUMERS`
lists. The refresh pipeline's own `:latest` toolbox references are correctly
excluded from bumping.
## 🟡 Suggestions
- **Manual trigger isn't branch-restricted, contradicting the stated invariant.**
`ci-claude-refresh.yml:68-69` gates the `manual` event on `PIPELINE ==
"ci-claude-refresh"` with no `branch:` filter. The header comment (lines
104-108) asserts "only master pushes images" and "a feature branch must never
build/push an image or open a bump PR" — but that guarantee comes from the
`push` filter's `branch: master` (line 70) alone. A manual run targeting a
feature branch (`woodpecker pipeline create --branch <feature> --var
PIPELINE=ci-claude-refresh`) would build/push `ci-claude:latest` from that
branch's Containerfile and open a bump PR. It's gated by manual-trigger
permission (repo write), so not externally exploitable, but it does break the
documented invariant. Consider adding `branch: master` (or an `evaluate` branch
check) to the manual condition too.
- **A failed PR POST leaves an orphan branch that permanently blocks that
version's bump.** In `bump-pr` (lines 178-181) the step exits early when
`$BRANCH` already exists on the remote. If a prior run pushed the branch (line
200) but the subsequent `curl` POST failed (lines 202-206 → `exit 1`), the
branch exists with no PR. Every later run for the *same* version sees the branch
and skips, so the PR is never retried until someone deletes the branch or a new
Claude version appears (which changes the branch name). Consider, when the
branch already exists, checking whether an open PR exists for it and creating one
if not — rather than unconditionally treating "branch exists" as "PR is open."
- **The decide guard checks tag *agreement*, not tag *presence*.**
`ci-claude-refresh.yml:106-114` computes distinct tags across the consumers and
aborts unless exactly one non-empty tag is found. If a consumer were renamed or
stopped pinning `ci-claude:` (so the `CONSUMERS` list drifts from reality), the
remaining files could still yield exactly one tag and pass the guard — silently
leaving the drifted file un-bumped. A cheap extra check that each listed file
actually contains a `ci-claude:` pin would catch drift.
## 🟢 Nits
- The `CONSUMERS` list is duplicated verbatim in the `decide` (lines 97-103) and
`bump-pr` (lines 182-188) steps. The `decide` comment already flags "keep this
list in sync with the consumers" — with two copies there are now three things to
keep in sync. Not worth a shared file, but worth being aware of.
## Tests
No automated tests accompany this change, which is consistent with the rest of
`.woodpecker/` (pipeline YAML isn't unit-tested in this repo). The reproducibility
claim rests on the author's manual two-build byte-diff verification, described in
the PR body; I could not independently reproduce that here (no docker in this
read-only checkout). If practical, the two-build digest comparison is the one
check that would confirm the layer actually dedups before relying on the cron.
## Recommendation
Looks reasonable — no blocking issues; comments above are optional.
<!-- pr-review:sha=f414f6e7bbe7634cc979f21dade387d257d715ee -->
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #351.
What was wrong
ci/images/claude/Containerfileinstalled a pinned Claude Code version, and every consumer pipeline pinned a stale dated image tag (ci-claude:2026-05-01, ~3 months old). Two separate problems:2.1.209(and never bumping the consumer tag) is an active liability.What this does
Containerfile— reproducible layer + track latest@latest. Verified on realnode:22-alpine: npm auto-selects theclaude-code-linux-x64-muslnative binary andclaude --versionruns. (ACLAUDE_CODE_VERSIONbuild-arg still lets you pin to dodge a bad release.)npm cache clean+rm -rf /root/.npm, then normalize install-time mtimes on the installed tree and every directory the RUN touches (parent-dir mtimes — e.g./usr— were the subtle part).docker build --no-cacheruns produce a byte-for-byte identical npm layer (every tar entry stamped 2020-01-01, zero metadata diffs) — exactly what the registry needs to dedup.ci-claude-refresh.yml(new) — keep it current, automaticallyci-claude:<version>(+:latest) and open a PR rewiring the 5 consumer pipelines. Nothing auto-merges.ci-claude:2.1.218) instead of dated tags — deliberate deviation. Makes "did it change?" a race-free string compare, keeps the cron from opening no-op PRs, self-documents which Claude version each pipeline runs. (Say the word if you'd rather keep dated tags.)build-ci-images.ymlno longer buildsci-claude(the refresh pipeline owns it, so they don't fight over tags).Credential — already provisioned
The bump credential (
ci_claude_bump_bot_token) is minted and wired:claude-botPAT scopedwrite:repositoryonly — verified 200 on the repo, 403 on/admin/users. (A dedicated Gitea user needs admin to create, whichclaude-botlacks; the scoped-down PAT is the same least-privilege at the token level.)ci_claude_bump_bot_token, scoped to thepush/cron/manualevents this pipeline fires on.claude-bot; theci_images_tokenstill does the kaniko push.⚠️ One step left — after merge
A cron references a pipeline that must already exist on the target branch, so register it once this is on
master:On merge, the push trigger builds
ci-claude:2.1.218and opens the first bump PR flipping the consumers2026-05-01→2.1.218.🤖 Generated with Claude Code
🤖 Automated review — model
claude-sonnet-5(complexity: normal), commita675bf6cf0b67401be1dfec718a62bda0da9a7d4.Summary
This PR (1) makes the
ci-claudeimage's install layer byte-reproducible by normalizing npm-cache/mtime churn inci/images/claude/Containerfile, defaultingCLAUDE_CODE_VERSIONtolatest, and (2) adds a newci-claude-refresh.ymlpipeline that resolves the latest published Claude Code version, rebuilds/pushesci-claude:<version>only when warranted, and opens a bump PR to rewire the 5 consumer pipelines.build-ci-images.ymlis trimmed to stop buildingci-claudeso the two pipelines don't race on the same tag. The new pipeline follows the repo's existing conventions closely (the$$-escaping, sentinel-file hand-off between steps, least-privilege token-per-pipeline pattern from #330,git ls-remotededup guard mirrored fromissue-fix.yml/security-scan.yml) and I did not find a concrete blocking defect. A few points below are worth a look before/soon after merge.🟡 Suggestions
ci/images/claude/Containerfile:36-46normalizes mtimes on@anthropic-ai/**,/usr/local/bin/claude, and a fixed list of parent directories, but a globalnpm install -gon npm ≥7 can also write a top-level/usr/local/lib/node_modules/.package-lock.json(outside the@anthropic-aitree thefindtargets and not in the explicittouchlist). If that file is present in this alpine/npm version, its mtime would still vary per build and defeat the layer-dedup goal the whole change is built around. The PR body states this was verified with two--no-cachebuilds producing byte-identical layers, which would already cover this — I just can't re-rundocker buildin this review environment to confirm independently. Worth double-checking that verification explicitly accounted for.package-lock.json(or that it doesn't exist for this install path).CONSUMERSlist is duplicated verbatim in thedecidestep (.woodpecker/ci-claude-refresh.yml:87-93) and thebump-prstep (:178-184). The comment abovedecidealready flags "keep this list in sync with the consumers" — since it's copy-pasted twice in the same file, it's easy for a future edit to update one and miss the other, silently causingbump-pr'ssedloop to skip a consumer thatdecide's guard already validated. Sourcing it from one place (e.g. write it to the same.ci-refreshsentinel dir indecideand read it back inbump-pr) would remove the duplication risk. Not blocking since both lists match today.bump-prstep if the owner-setup isn't done yet, exactly as the PR body anticipates: the push from this PR's own merge matches thepathtrigger (ci/images/claude/**,.woodpecker/ci-claude-refresh.yml),decidewill seeCUR=2026-05-01 != NEW=<latest>and setdo-bump, and ifci_claude_bump_bot_tokenisn't provisioned yet thegit pushinbump-pr(.woodpecker/ci-claude-refresh.yml:186) will attempt an unauthenticated push and fail the step. This is called out in the PR description as an expected, non-blocking outcome (build already succeeded by then), so just flagging it's worth provisioning the token first to avoid a red pipeline on merge.🟢 Nits
.woodpecker/ci-claude-refresh.yml:97— the version-format guard[0-9]*.[0-9]*.[0-9]*is a shell glob, not a regex; it's loose enough to accept things like pre-release suffixes (2.1.218-beta.1). That's harmless given it only gates against "npm returned garbage," but worth knowing it isn't a strict semver check.Tests
No automated tests exist for Woodpecker pipeline YAML in this repo (consistent with the rest of
.woodpecker/), so this is unavoidably untested by CI until it actually runs. The Containerfile change is the one part the author says was independently verified (two--no-cachebuilds, byte-identical layer). The pipeline logic itself (decide/build/bump-prstep interplay, sentinel hand-off via$CI_WORKSPACE, thegit ls-remotededup check) has not been exercised end-to-end — it can't be until the owner completes the one-time setup (bot identity + token + cron registration) called out in the PR body. Recommend watching the first real run (triggered by this PR's own merge) closely rather than treating it as done once merged.Recommendation
Looks reasonable — no blocking issues; comments above are optional.
🤖 Automated review — model
claude-sonnet-5(complexity: normal), commit16eecf07e8d2440bc5f37f074814fa5cbf7fd7cb.Summary
This PR (1) makes the
ci-claudeimage build byte-reproducible by stripping npm-cachechurn and normalizing install-time mtimes, (2) switches its default Claude Code version
to
latest, and (3) adds a newci-claude-refresh.ymlpipeline that resolves thelatest published version weekly, rebuilds/pushes a version-tagged image only when
warranted, and opens a human-reviewed PR to bump the 5 consumer pipelines. I checked the
new pipeline's shell logic, its Woodpecker variable/escaping conventions, and its
interaction with
build-ci-images.ymland the 5 consumer files against the rest of therepo, and did not find a concrete defect. No blocking issues.
🟡 Suggestions
ci-claude-refresh.yml:96-102and:187-193— theCONSUMERSfile list isduplicated verbatim in both the
decideandbump-prsteps (with a comment in eachtelling a human to keep it in sync). Since both steps already share state via
$CI_WORKSPACE/.ci-refresh/, thedecidestep could write the list there once andbump-prcould read it back, so a future consumer addition only needs updating in oneplace instead of two that must be kept manually consistent.
ci-claude-refresh.yml:169-172— thegit ls-remote --heads ... | grep -q .branch-existence check and the later
git pushare not atomic: two overlapping runs(e.g. a manual trigger firing while the weekly cron is still running) could both pass
the check and then race on the push/PR-open. Given this only runs on a weekly cron +
occasional manual/push trigger, the odds are low, but a cheap mitigation would be to
let the second
git push/PR-create simply fail-and-log rather than assuming the checkfully prevents the race.
docker build --no-cacheruns produced byte-identical layers; I can't re-verify that claim from astatic diff review (would need to actually build), but the
Containerfile:274-303logic (cache purge + recursive mtime normalization of
/usr/local/lib/node_modules/@anthropic-aiand every touched parent dir up to
/usrand/root) is coherent with what would berequired to achieve it, and matches the specific problem described (parent-dir mtimes).
🟢 Nits
Containerfile:293-294—find .../@anthropic-ai -exec touch ... {} +alreadytouches the
@anthropic-aidirectory itself (find includes the starting path), so theexplicit
touch -h -t ... /usr/local/lib/node_modules/@anthropic-aia few lines downis redundant. Harmless (touch is idempotent), just a duplicate no-op.
Tests
No automated tests exist for Woodpecker pipeline YAML anywhere else in this repo, so the
absence of any here isn't a regression versus repo norms. The author states the
reproducibility property was verified manually with two
docker build --no-cacheruns;that verification isn't captured in CI, so a future edit to the
Containerfileinstalllayer could silently reintroduce non-determinism with nothing to catch it. Not something
I'd block on given the repo's existing conventions, but worth knowing.
I specifically checked (all consistent with the rest of the repo, not just asserted):
$$VAR/$${VAR:-}escaping matches the convention used inpr-review.ymlandothers (Woodpecker substitutes over the whole YAML including comments).
CI_PIPELINE_EVENT,CI_WORKSPACE, and theevaluate: 'PIPELINE == "..."'patternare all used elsewhere in this repo's pipelines the same way.
CONSUMERSpaths (security-scan.yml,alert-monitor.yml,update-audit.yml,pr-review.yml,issue-fix.yml) each currently pin exactlyci-claude:2026-05-01—confirmed by grepping the checkout — so the "exactly one tag" guard in the
decidestep will pass on the very first run.
git push "https://$$PUSH_TOKEN@..."pattern matches the existingusage in
issue-fix.yml:196andsecurity-scan.yml:119, so this isn't a new riskpattern introduced by this PR.
build-ci-images.yml's removal of thebuild-claudestep is clean — it was anindependent (
depends_on: []) step and no other step referenced it.decide/bump-prsteps never invoke theclaudeCLI (they use theci-claudeimage purely as an npm/jq/git/curl toolbox), so
PUSH_TOKENis never exposed to anLLM agent context — unlike
pr-review.yml, noenv -uisolation is needed here.Recommendation
Looks reasonable — no blocking issues; comments above are optional.
🤖 Automated review — model
claude-opus-4-8(complexity: normal), commitf414f6e7bbe7634cc979f21dade387d257d715ee.Summary
This PR makes the
ci-claudeimage build reproducible (byte-stable npm layer soregistry dedup works) and moves it to tracking the latest Claude Code via a new
ci-claude-refresh.ymlpipeline that resolves the version, conditionally rebuilds,and opens a bump PR rewiring the 5 consumer pipelines. The design is careful — the
decide/build/bump gating, the "exactly one tag" guard, and the version/content
agreement via
--build-argare all sound. No blocking defects found; the notesbelow are hardening and robustness suggestions.
I verified the consumer list: exactly 5 files pin
ci-claude:2026-05-01(
issue-fix.yml:65,security-scan.yml:45,alert-monitor.yml:104,pr-review.yml:63,update-audit.yml:33), matching both hardcodedCONSUMERSlists. The refresh pipeline's own
:latesttoolbox references are correctlyexcluded from bumping.
🟡 Suggestions
Manual trigger isn't branch-restricted, contradicting the stated invariant.
ci-claude-refresh.yml:68-69gates themanualevent onPIPELINE == "ci-claude-refresh"with nobranch:filter. The header comment (lines104-108) asserts "only master pushes images" and "a feature branch must never
build/push an image or open a bump PR" — but that guarantee comes from the
pushfilter'sbranch: master(line 70) alone. A manual run targeting afeature branch (
woodpecker pipeline create --branch <feature> --var PIPELINE=ci-claude-refresh) would build/pushci-claude:latestfrom thatbranch's Containerfile and open a bump PR. It's gated by manual-trigger
permission (repo write), so not externally exploitable, but it does break the
documented invariant. Consider adding
branch: master(or anevaluatebranchcheck) to the manual condition too.
A failed PR POST leaves an orphan branch that permanently blocks that
version's bump. In
bump-pr(lines 178-181) the step exits early when$BRANCHalready exists on the remote. If a prior run pushed the branch (line200) but the subsequent
curlPOST failed (lines 202-206 →exit 1), thebranch exists with no PR. Every later run for the same version sees the branch
and skips, so the PR is never retried until someone deletes the branch or a new
Claude version appears (which changes the branch name). Consider, when the
branch already exists, checking whether an open PR exists for it and creating one
if not — rather than unconditionally treating "branch exists" as "PR is open."
The decide guard checks tag agreement, not tag presence.
ci-claude-refresh.yml:106-114computes distinct tags across the consumers andaborts unless exactly one non-empty tag is found. If a consumer were renamed or
stopped pinning
ci-claude:(so theCONSUMERSlist drifts from reality), theremaining files could still yield exactly one tag and pass the guard — silently
leaving the drifted file un-bumped. A cheap extra check that each listed file
actually contains a
ci-claude:pin would catch drift.🟢 Nits
CONSUMERSlist is duplicated verbatim in thedecide(lines 97-103) andbump-pr(lines 182-188) steps. Thedecidecomment already flags "keep thislist in sync with the consumers" — with two copies there are now three things to
keep in sync. Not worth a shared file, but worth being aware of.
Tests
No automated tests accompany this change, which is consistent with the rest of
.woodpecker/(pipeline YAML isn't unit-tested in this repo). The reproducibilityclaim rests on the author's manual two-build byte-diff verification, described in
the PR body; I could not independently reproduce that here (no docker in this
read-only checkout). If practical, the two-build digest comparison is the one
check that would confirm the layer actually dedups before relying on the cron.
Recommendation
Looks reasonable — no blocking issues; comments above are optional.