Skip to content

feat(devcontainers): switch to zsh, install gh upstream, widen Renovate coverage - #146

Merged
gatezh merged 6 commits into
masterfrom
chore/renovate-coverage-zsh-migration
Sep 17, 2026
Merged

gatezh merged 6 commits into
masterfrom
chore/renovate-coverage-zsh-migration

Conversation

@gatezh

@gatezh gatezh commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

What

Replaces fish with zsh + oh-my-zsh + powerlevel10k across the remaining images, installs gh from the upstream .deb instead of apt, widens Renovate coverage from 4 packages to 11, and drops git-delta.

Why

gh was ~2.5 years stale (2.46.0, Apr 2024). That was never a Renovate problem — Debian trixie freezes the package version for the life of the release, so there was no version string for Renovate to bump and no config change could have fixed it. The install source had to change.

Auditing the rest turned up six more pinned tools that Renovate was blind to, because the custom regex manager matches the # renovate: comment as part of its pattern and those ARGs didn't have one. BUN_VERSION had drifted three ways across five images as a result.

fish was costing 26 apt packages and 59 MB — including the entire Python 3.13 runtime — for a shell that causes syntax friction. zsh costs 2 packages and 20 MB, and every other image in the repo already used it.

Changes

gh

  • Installed from the upstream .deb (2.46.0 → 2.100.0) in claude-code, claude-bun and the repo devcontainer, reusing the dpkg -i idiom already present in those files
  • Must stay after the apt block — the .deb depends on git and dpkg -i does not resolve dependencies

Renovate: 4 → 11 packages

  • Added gh, zsh-in-docker, docker, go, and the hugo-bun-node musl node build
  • node constrained to ^24; group renamed devcontainer agent tools → devcontainer tools since it now carries non-agent tooling
  • Docker uses github-tags on docker/cli, not github-releases on moby/moby — moby tags releases docker-v29.8.0, which extractVersion cannot parse, so every candidate would have been silently discarded. Same trap the ralphex-fe bun rule already documents

Shell

  • fish → zsh + oh-my-zsh + powerlevel10k (git and fzf plugins) in claude-code and .devcontainer; every image now defaults to zsh
  • starship dropped — p10k replaces it, removing an unpinned curl | sh install
  • History volume *-fish-data-* → *-zsh-history-*, retargeted to /commandhistory to match claude-bun's existing convention

Removed

  • git-delta from all three images, its Renovate entry, its build arg and its CI checks

Reliability

  • sh -c "$(curl ...)" exits 0 when the download fails, shipping an image with no .zshrc and no oh-my-zsh while CI's zsh --version still passed. Now downloads to a file first. Verified with a bogus version: the old form built successfully, the new form fails
  • Publish workflows now verify gh (claude-code, claude-bun) and go/docker (ralphex-fe), matching ci.yml
  • /commandhistory added to the claude-code default variant's chown — only the sandbox variant had it, so history would have silently failed to persist
  • Dropped a stale ZSH_IN_DOCKER_VERSION build arg in claude-bun/devcontainer.json that would have pinned back over every Renovate bump for local builds

Notes

Redundancy removed after review — three settings turned out to be no-ops, each confirmed against upstream source:

  • source <(fzf --zsh) — oh-my-zsh's fzf plugin already runs eval "$(fzf --zsh)" for fzf ≥ 0.48 (trixie ships 0.60)
  • setopt SHARE_HISTORY — oh-my-zsh's lib/history.zsh:48 sets it unconditionally
  • setopt INC_APPEND_HISTORY — zsh's manual: "should be turned off if [SHARE_HISTORY] is in effect"

Requires a Nerd Font in the terminal for powerlevel10k glyphs (terminal.integrated.fontFamily). claude-bun already used p10k, so anyone using that image is already set.

Orphaned volumes — the old *-fish-data-* volumes are not deleted, just no longer mounted.

Wiki follow-up: #147 — the host-level Docker runbooks moved to the wiki upstream in 2e1a3e7 and still reference *-fish-data volumes, which this PR renames. Tracked in #147 since the wiki is a separate repository. This matters more than it looks: those pages list *-fish-data under "do not prune", so following them after this merge would find no such volume and could lead to pruning the real one.

Not changed, deliberately — neither the gh .deb nor the zsh-in-docker script is checksum-verified, and the latter clones oh-my-zsh from master and p10k from HEAD. Downloads are HTTPS with cert validation; a checksum from the same origin would mainly catch CDN corruption, not a compromised release, and would do nothing about the unpinned clones — which are the real exposure now that these bumps auto-merge to ghcr.io. Pinning both to commit SHAs is the real fix if we want one.

Verification

  • Renovate config validates as a repo config; all 11 annotations resolve against the live matchStrings regex
  • actionlint clean; hadolint across 7 Dockerfiles shows zero new findings vs master
  • claude-code (default + sandbox) and claude-bun built on arm64; all three ci.yml verify commands pass, extracted from the workflow rather than retyped
  • gh 2.100.0 confirmed installing and running on both arm64 and amd64
  • Interactive zsh starts clean in all images: p10k theme, git/fzf plugins, gst alias, fzf widgets bound

…te coverage

Debian trixie freezes gh at 2.46.0 (Apr 2024) and always will, so no
Renovate config could have fixed it -- the install source had to change.
gh now comes from the upstream .deb at 2.100.0, using the same dpkg -i
idiom these images already used for local .deb packages.

Renovate coverage grows from 4 packages to 11: gh, zsh-in-docker, docker,
go and the hugo-bun-node musl node build join rtk, ralphex, claude-code,
agent-browser, bun and hugo. All auto-merge on the existing 3-day soak.
node is constrained to ^24 so major bumps stay a deliberate call.

Docker is tracked with github-tags on docker/cli rather than
github-releases on moby/moby: moby tags its releases "docker-v29.8.0",
which extractVersion cannot parse, so every candidate would have been
silently discarded -- the same trap the ralphex-fe bun rule documents.

fish is replaced by zsh + oh-my-zsh + powerlevel10k (git and fzf plugins)
in claude-code and the repo devcontainer, so every image now defaults to
zsh. fish pulled 26 apt packages and 59 MB, including the entire Python
3.13 runtime; zsh pulls 2 and 20 MB. starship is dropped -- powerlevel10k
replaces it, which also removes an unpinned "curl | sh" install.

git-delta is removed from all three images.

Reliability fix: the zsh-in-docker installer ran as sh -c "$(curl ...)",
which exits 0 when the download fails -- shipping an image with no
.zshrc and no oh-my-zsh while CI's "zsh --version" still passed. It now
downloads to a file first, so a failed fetch aborts the build. Verified
by building with a bogus version: the old form succeeded, the new one
fails.

The publish workflows now verify gh (claude-code, claude-bun) and go and
docker (ralphex-fe), matching what ci.yml checks.

Host-level Docker runbooks moved to the wiki upstream; the volume rename
from *-fish-data-* to *-zsh-history-* still needs applying there.
… verify gaps

Post-rebase review of the zsh migration. Two user-visible regressions, both
reproduced in a built image before and after the fix.

The zsh-in-docker template hardcodes en_US.UTF-8 and TERM=xterm, and -x skips
the locale-gen that would create that locale. The slim bases ship only
C/C.utf8/POSIX, so glibc silently fell back to the C locale: `echo "hello"
| wc -m` counted bytes, not characters, mangling multibyte output in git log
and TUIs. The baked TERM=xterm also clobbered the xterm-256color the terminal
provides, dropping `tput colors` from 256 to 8 -- a visible downgrade on the
flagship image, since the fish/starship setup left TERM alone. Both are now
rewritten out of .zshrc: C.UTF-8 is a real UTF-8 locale needing no extra
package, and dropping the TERM line lets the terminal decide.

Dead code: claude-bun assigned SNIPPET and never used it. The upstream
Anthropic pattern ends with `echo "$SNIPPET" >> ~/.bashrc`, which was never
copied across -- so bash history never persisted despite the comment saying
it did. Removing it also retires the SC2034 hadolint suppression, which
existed solely to hide that variable; no findings return without it.

Verify gaps: ci.yml checked docker for ralphex-fe but build-ralphex-fe.yml
did not, and build-claude-bun.yml checked zsh but ci.yml did not -- so a
regression could pass one gate and fail the other. Both directions closed.

Comment accuracy: zsh was installed but absent from the package-rationale
blocks, gh was still listed there though it no longer comes from apt, and
the fzf entry claimed an explicit .zshrc source that this branch removed.
The supply-chain trade is now stated in the Dockerfiles: ZSH_IN_DOCKER_VERSION
pins only the wrapper, which fetches oh-my-zsh and powerlevel10k unpinned at
build time. The stale devcontainer plan doc gets the same superseded banner
the renovate spec already carries.
The previous commit patched .zshrc by matching the exact lines zsh-in-docker
v1.2.0 emits. That was already broken against the currently released v1.2.1,
which writes `[ -z "$TERM" ] && export TERM=xterm` inside an UNQUOTED heredoc
-- so $TERM expands at build time, when it is unset, to `[ -z "" ] && export
TERM=xterm`, unconditionally true and invisible to a pattern anchored on the
old text. sed exits 0 on a non-match, so the fix would have silently stopped
working. Verified by building against both releases: on v1.2.1 the old sed
leaves TERM clobbered to xterm.

Since zsh-in-docker auto-merges after its soak, Renovate would have adopted
v1.2.1 and reintroduced the 8-colour regression the previous commit fixed.

Now the whole template preamble is cut at the `##### Zsh/Oh-my-Zsh
Configuration` marker, which is identical in both releases, instead of matching
individual lines. A grep guard precedes it: without one, a marker rename would
make sed delete the entire file rather than nothing. Verified against v1.2.0
and v1.2.1 -- TERM survives and the theme, plugins and history all still load.

Locale moves from .zshrc to ENV LANG/LC_ALL on the image. The .zshrc-only fix
covered interactive zsh and nothing else: postCreateCommand and
updateContentCommand run under sh, as do the VS Code server, docker exec and
every bash Claude Code spawns -- all still saw ANSI_X3.4-1968 and mangled
multibyte output, which is the bug the previous commit set out to fix.

CI now asserts the outcome rather than trusting the transformation, on all
eight verify paths: LC_ALL resolves to C.UTF-8 and a sentinel TERM survives
shell startup. Both were checked negatively -- they fail against a v1.2.1
image and against a hand-regressed .zshrc. The sentinel is deliberately not
xterm-256color, which would also pass if a template ever hardcoded it.

Also corrected: both READMEs advertised tools no image contains (nano, vim,
wget, unzip, man-db for claude-code; vim, nano for claude-bun -- all verified
absent in a build), and the renovate.json5 extractVersion rationale cited
git-delta, which this branch deletes everywhere.
…h-in-docker

zsh-in-docker generates a .zshrc from its own template, and every fix in this
branch so far has been a skirmish with that template: an anchored sed to strip
its TERM line, then a preamble cut when v1.2.1 changed the line, then CI
assertions to catch the next change. The template was the problem, not any of
the individual patches.

Now oh-my-zsh and powerlevel10k are fetched as tarballs pinned to commit SHAs
in a parallel download stage, and .zshrc is written here. Nothing upstream can
inject env overrides that then have to be patched back out.

Pinned by SHA rather than tracked by Renovate, deliberately. Neither project
tags usefully -- oh-my-zsh has no tags at all and powerlevel10k's last release
predates its master by years -- and auto-adopting shell-framework changes is
exactly what kept breaking this image. A prompt theme does not need a 3-day
release cadence.

Environment is now what the official images do: ENV LANG=C.UTF-8 and nothing
else. LC_ALL is dropped -- it outranks every LC_* category, so a consuming
project's containerEnv could not override one. TERM is dropped too: no official
image sets it, an image ENV beats the tty value docker exec -t supplies, and
tput failing without a terminal is correct behaviour rather than a bug to paper
over.

The two locale/TERM verify assertions are replaced by one that exercises the
whole chain -- an interactive zsh reporting the powerlevel10k theme, which is
only true if both pinned tarballs and the generated .zshrc are intact. Checked
negatively: it fails against a hand-broken .zshrc.

Removes deluan/zsh-in-docker as a dependency, its Renovate entry and its ARG.
Renovate now tracks 10 packages.

Verified on all four image variants (claude-code default and sandbox,
claude-bun, the repo devcontainer): theme, plugins, git aliases, fzf widgets,
HISTFILE and locale all correct; no configuration wizard; prompt renders in a
git repo with --network none, so nothing is fetched at first shell.
The theme assertion added in ce1f39f was written as

    zsh -ic 'echo $ZSH_THEME' | grep -qx powerlevel10k/powerlevel10k

but in ci.yml it sits inside a double-quoted bash argument to add_image, so
the runner's shell expanded $ZSH_THEME -- to empty, since it is a zsh variable
the runner has never heard of -- before the string was ever stored. What
actually ran in the container was

    zsh -ic 'echo ' | grep -qx powerlevel10k/powerlevel10k

which prints an empty line and fails. Build · claude-bun went red on amd64.

Uses typeset -p ZSH_THEME instead, which contains no $ at all and so survives
every quoting layer identically -- ci.yml builds its string in bash while the
publish workflows pass theirs through an env var, and escaping would have had
to differ between the two.

This slipped through local verification because the harness extracted the
verify string from the YAML text and re-quoted it in single quotes, which
preserved $ZSH_THEME. The harness now assigns the original double-quoted
literal through bash first, so runner-side expansion is reproduced; confirmed
by feeding it the broken form, which it now fails.
… table

The table restated each ARG's current value, so every Renovate bump silently
falsified it -- it drifted twice during this branch alone, once for rtk and
claude-code and again for a claude-code bump landing mid-review. A number that
is wrong more often than right is worse than no number.

The table now says who updates each arg and why it exists, and points at the
Dockerfile for current values.
@gatezh
gatezh force-pushed the chore/renovate-coverage-zsh-migration branch from f6efa8d to 606d1a2 Compare September 17, 2026 02:45
@gatezh
gatezh merged commit 24afdcf into master Sep 17, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant