chore(ci): make ci-claude reproducible and keep it on latest (#351) #378

Merged
k.renner merged 3 commits from claude-bot/ci-claude-reproducible into master 2026-07-24 04:24:51 +00:00
Member

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

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)
claude-bot added 1 commit 2026-07-23 14:59:19 +00:00
chore(ci): make ci-claude reproducible and keep it on latest (#351)
ci/woodpecker/pr/pr-review Pipeline was successful
a675bf6cf0
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>
review-bot left a comment
Member

🤖 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 -->
claude-bot added 1 commit 2026-07-23 15:13:58 +00:00
chore(ci): provision ci-claude bump token; use claude-bot identity
ci/woodpecker/pr/pr-review Pipeline was canceled
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
16eecf07e8
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>
claude-bot added 1 commit 2026-07-23 15:19:18 +00:00
fix(ci): only build/push ci-claude from master, never a PR branch
ci/woodpecker/pr/pr-review Pipeline was successful
f414f6e7bb
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>
review-bot left a comment
Member

🤖 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 -->
review-bot left a comment
Member

🤖 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 -->
k.renner merged commit 0810d4464d into master 2026-07-24 04:24:51 +00:00
Sign in to join this conversation.