Shared devcontainer Features for RTLDEV
middleware repositories, published to ghcr.io and consumed by version rather than
copied.
| Feature | What it provides |
|---|---|
devbase |
zsh with the team prompt, commitizen, pnpm, the gh credential helper, persistent shell history, dependency installation, attach banner |
Add one entry to your repository's .devcontainer/devcontainer.json:
Only the second line is strictly required. devbase declares node (at lts) and
github-cli in dependsOn, so it installs both itself, before itself. The node entry
above is kept because it deduplicates with devbase's own — same feature, same options,
one install — and it documents the runtime at the point people look for it. Other runtimes
(php, go, python, java) are genuinely yours: devbase installs none of those and only
orders itself after them.
Rebuild the container. That is the whole integration for a repository whose defaults are fine.
Starting a repository from scratch? Copy a devcontainer frame from rtldev-middleware-template instead — the frames come with this entry already in place.
Contents
- The
devbaseFeature - Why a Feature and not copied files
- Options
- Configuring the attach banner
- Migrating a repository that already has a devcontainer
- Developing a Feature
- Publishing
- Migrating to 2.0.0
- Keeping consumers up to date
- Troubleshooting
devbase is the shared behaviour half of our devcontainer setup. It installs no
language runtime of its own — php, go, python and java stay in each repository's feature
list, and devbase declares installsAfter for them so its setup steps run once those
runtimes exist. Node is the exception, and a deliberate one: see below.
Two features it does not leave to the consumer, declared in dependsOn rather than
installsAfter: github-cli, because the gh credential helper below is useless
without gh; and node at lts, because devbase's own pnpm, commitizen and
npm-floor steps need npm. installsAfter is only a hint: it orders a feature the consumer
already listed and does nothing when they did not, so it could never have carried either.
claude-codewas a third, until 2.0.0 removed it. It was there "because it is on every one of our machines anyway", and that turned out to be the whole of the reason — nothing in this Feature, and nothing in any consuming repository's scripts or workflows, ever called theclaudeCLI. Meanwhile the VS Code extension ships and runs its own runtime (resources/native-binary/claude, inside the extension directory), so the npm-installed global was a second copy of the same thing — and, being installed as root into a global npm tree the node feature makes user-owned, a copy that could not update itself:claude updatefailed until someone chowned it by hand. See Migrating to 2.0.0.It also used to be half the reason
nodeis a dependency:claude-codeinstalls Node 18 from nodesource — EOL since April 2025 — when it cannot find a Node of its own. That reason retires with it. The other half stands unchanged, which is whynodestays.
On the Node pin. Keep
"ghcr.io/devcontainers/features/node:2": { "version": "lts" }in yourdevcontainer.jsonif it is already there — identical options deduplicate, so it collapses intodevbase's own entry and changes nothing. It is now optional rather than required.What you cannot do is pin a different version. Differing options do not deduplicate: both instances install,
devbase's runs second, and itsdefault -> lts/*replaces yours — a repository asking for 22 measurably ended up on 24. Sodevbaseowns the Node version for every consumer. If a repository genuinely needs another major, that is a change todevbase'sdependsOnand a release, not something to arrange locally. Thenode_pinnedscenario asserts the override so it stays visible rather than surprising.A knock-on: the Node feature ships
pnpmVersion: latest, so pnpm is present in every container beforedevbaseruns. TheinstallPnpmoption therefore decided nothing and has been removed — pnpm is now simply part of what you get. Nothing consumes this Feature yet, which is the only reason dropping an option did not need a major.
latestis also whydevbasedoes not leave the pnpm version to that feature: it means whichever release was newest on the day the image was built, while CI runs the one yourpackage.jsondeclares.devbaseinstalls the declared one instead — see the pnpm version.
What it does, on first create and on every attach:
- zsh with the team prompt (git status segment, history search, autosuggestions)
- commitizen plus
cz-conventional-changelog, and the matching.czrc - pnpm, installed globally with
PNPM_HOMEonPATH, at the version yourpackage.jsondeclares indevEngines.packageManagerorpackageManager— the same two fields CI reads, in the same order, so the container and the pipeline cannot disagree - npm raised to the major
engines.npmnames, for the floor no Node release bundles ghcredential helper wired into the workspace's git config- Persistent shell history across container rebuilds
- Dependency installation —
composer.jsonvia composer,package.jsonvia pnpm,.envseeded from.env.example - A UTF-8 locale (
LANG=C.UTF-8) for the whole container, not just interactive shells - An attach banner reporting the container, language and dependency versions
- The shared VS Code extension set, the zsh terminal profile and a few editor
settings — shellcheck,
npm.packageManager, andfiles.excludehiding**/node_modules
On overriding the VS Code settings. A setting you declare in your own
devcontainer.jsonwins over the Feature's, and for an object-valued setting that is a whole-object replacement, not a deep merge — VS Code does not merge object values across settings scopes.
files.excludeis the one this bites. A repository that adds**/vendorby declaring its ownfiles.excludesilently loses the inherited**/node_modulesentry, because its object replaces the Feature's rather than extending it. Restate what you want to keep:"customizations": { "vscode": { "settings": { "files.exclude": { "**/node_modules": true, // restate, or you lose it "**/vendor": true } } } }Which also means there is no separate opt-out to build:
"**/node_modules": falseun-hides it, and so does simply leaving the key out of your own object.
On the locale. Our base images ship no
LANG, which leaves the C library in theClocale with an ASCII charmap — inherited by every tool that reads a file, so any source file with an em-dash in a comment decodes wrong somewhere. The Feature declarescontainerEnv: { "LANG": "C.UTF-8" }, which the CLI bakes into the image as anENV, so it reaches non-interactive processes and lifecycle commands too — a/etc/profile.dsnippet would not.
C.UTF-8is built into glibc (nolocalespackage, nolocale-gen) and collates by codepoint exactly asCdoes, sosortand[a-z]ranges in existing scripts are unaffected. Only the charmap changes.There is no option for it, on the same reasoning that keeps
mountsunused:containerEnvis static JSON with no option substitution, so a flag could not switch it off and would only be a lie. SetcontainerEnv.LANGin your owndevcontainer.jsonto override — the CLI emits the consumer'scontainerEnvafter the feature layer, so it wins — and make sure the locale you name exists in the image, becausesetlocalefalls back toCin silence when it does not.
Because the container frames differ across our repositories and the behaviour does not.
php-sdk and mcp-dis build a single container from a Dockerfile; whmcs-src runs its
dev container as one service in a four-service compose stack. No shared
devcontainer.json or Dockerfile spans those. What was shared, before this
Feature existed, was the behaviour — and it had been copy-pasted into each repository
and then drifted: the log_*/execute_with_indent/setup_pnpm block existed in
three near-identical copies, .zshrc differed by 29 lines between two repositories
for no reason anyone chose, and the attach banner had been reinvented three times.
A Feature installs into a container built either way, is versioned, and is pinned by
digest in each consumer's devcontainer-lock.json. Copied files are none of those things.
Getting a new version into that lock is its own problem — see
Keeping consumers up to date.
All optional; the defaults are what php-sdk and mcp-dis want.
| Option | Default | What it does |
|---|---|---|
commonPackages |
true |
Installs wget jq git zip unzip curl zsh shellcheck. Turn off for a base image that already has them. |
timezone |
Europe/Berlin |
Written to /etc/localtime and /etc/timezone. Empty string leaves the image alone. |
globalPackages |
commitizen@latest,cz-conventional-changelog@latest |
Comma-separated global pnpm installs. Empty installs nothing. |
zshAutosuggestions |
true |
Installs the zsh-autosuggestions plugin. |
historyPersistence |
true |
Symlinks ~/.zsh_history to /WSL_USER/.zsh_history. Needs the frame to bind the host's ~/.zsh_history at that path — the file, not the home directory; skipped silently if not. |
ghCredentialHelper |
true |
Points the workspace git credential helper at gh auth git-credential. |
sshCommitSigning |
true |
Repairs SSH commit signing when a mounted host ~/.gitconfig points user.signingkey at a key file the container lacks; uses the forwarded ssh-agent and writes an inline key:: key to the workspace git config. Never enables signing you did not configure. |
envInfoBanner |
true |
Prints the toolchain banner on attach; also installs devbase-env-info. |
installProjectDependencies |
true |
Installs from composer.json / package.json and seeds .env from .env.example. |
autoloadEnvScript |
true |
Sources a workspace env.sh from ~/.zshenv so new terminals inherit it. |
installRtk |
true |
Installs RTK, the token-optimizing CLI proxy for Claude Code. Binary only — the hook stays in the mounted ~/.claude. |
rtkVersion |
0.45.0 |
RTK release to install, without the leading v. Checksum-verified against the release's checksums.txt. |
A stack elaborate enough to own its own setup turns the generic part off:
"ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:2": {
"installProjectDependencies": false,
"timezone": "Europe/London"
}Two things worth knowing about consuming it:
- Pin the major (
:1), not a patch. A new1.xis picked up on the next rebuild, and the digest recorded indevcontainer-lock.jsonkeeps the build reproducible in between. - Lifecycle order. The Feature's
postCreateCommandandpostAttachCommandrun before the ones in yourdevcontainer.json, so your own hooks can rely on pnpm, the global packages and your dependencies already being installed. Put repository-specific setup there — never fork the Feature's scripts.
RTK is a token-optimizing CLI proxy for Claude Code: a
PreToolUse hook rewrites shell commands and filters their output, cutting a large share
of the tokens tool results otherwise consume.
It is installed here, rather than in each repository's Dockerfile, because of an
asymmetry (RSRMID-2933). The hook lives in ~/.claude/settings.json, which every frame
bind-mounts from the host — so the configuration is shared between host and container
while the binary is not. A container without rtk fires a hook that exits 127 on
every Bash call: no savings, plus an error each time. Installing it centrally means the
binary follows the hook into every repository instead of being re-pasted into each one.
RTK needs two halves, and they are centralised in different places:
| Half | Lives in | Why there |
|---|---|---|
the rtk binary |
this Feature | it must exist inside the container, which is what the Feature builds |
the PreToolUse hook that calls it |
the consuming repository's committed .claude/settings.json |
it is configuration: reviewable, versioned, and identical for everyone on the team |
The hook is not written by this Feature, and that is deliberate. The only per-user
settings file in the container is ~/.claude/settings.json, which every frame bind-mounts
from the host — writing it would edit the developer's own workstation configuration from
inside a container, and rtk init -g is never run for the same reason.
Leaving the hook to each developer's personal ~/.claude was the other option, and it is
what the original arrangement did. It means RTK is active for whoever configured it by hand
and inert for everyone else, which is the team-wide-versus-personal problem restated rather
than solved. So the hook belongs in the repository, and
rtldev-middleware-template
ships it in .claude/settings.json:
{
"type": "command",
"command": "command -v rtk >/dev/null 2>&1 && exec rtk hook claude || exit 0",
"statusMessage": "Optimizing command output (RTK)"
}The guard is the load-bearing part. That one committed file is read on the host, in CI
and inside the container, but only the container is guaranteed to have rtk. Unguarded, it
would exit 127 on every Bash call made outside the container — the same defect as a
container with the hook but no binary, pointing the other way. Guarded, it activates
precisely where the Feature has installed the binary and is a silent no-op everywhere else.
Remove the personal copy. If you already have
rtk hook claudein your own~/.claude/settings.json, delete it once a repository carries the hook. Hooks from user and project settings both fire, and this one returns anupdatedInputthat rewrites the command — two hooks rewriting the same tool call is not a defined outcome.
- Only the binary.
rtk init -gis never run — it would rewrite the bind-mounted~/.claude/settings.json, which is shared with the host. - Pinned and checksum-verified. The version is an option, and the download is checked
against the release's own
checksums.txt, so a curl-fetched third-party binary is not an unverified supply-chain surface. - Fails the build rather than warning. RTK is opted into; continuing without it leaves
a hook erroring on every Bash call, which is harder to diagnose than a build that stops
and says why. Use
"installRtk": falseto opt out — per repository, or per developer via a local config.
Upstream publishes a musl build for x86_64 but only a gnu build for aarch64, so
the two architectures do not share a naming pattern; install.sh maps uname -m to the
right asset and skips with a warning on an architecture with no published build.
The banner shows the installed version, which is where "hook configured but binary missing" becomes visible.
With no configuration the banner titles itself from the repository directory name and
shows each language group whose runtime is present. To override, add
.devcontainer/env-info.conf to the consuming repository:
TITLE="PHP-SDK - development environment"
SHOW_PHP=auto # auto | true | false, likewise SHOW_NODE/GO/PYTHON/JAVA
PHP_EXTENSIONS="curl intl xdebug"
PHP_NOTE="(language-feature ceiling: 8.3)"
NODE_DEPS="@modelcontextprotocol/sdk express zod"
COMPOSER_DEPS=""
EXTRA_ROWS="Apache|apache2 -v|3
MariaDB|mariadb --version|"The file is sourced as shell, so quote values containing spaces. EXTRA_ROWS takes one
Label|command|field-index per line, where the field index picks a whitespace-separated
field (1–3) and defaults to the whole first line of output.
Run devbase-env-info to see the result without reattaching.
Dependency versions are read from node_modules/ and vendor/, never from the
manifest, so an empty row means "install has not run" rather than "unknown" — the
distinction that makes the banner worth reading.
Roughly 30 minutes per repository. Work on a branch and rebuild before you delete anything.
-
Add the Feature to
devcontainer.json'sfeaturesblock. -
Delete what it replaces. For a repository on the php-sdk/mcp-dis pattern, that is the whole shared
supporting_files/tree:.devcontainer/supporting_files/scripts/post-create.sh .devcontainer/supporting_files/scripts/post-attach.sh .devcontainer/supporting_files/scripts/env-info.sh .devcontainer/supporting_files/configuration/home/.zshrc .devcontainer/supporting_files/configuration/home/.czrcKeep anything genuinely repository-specific — php-sdk's
phpunit-wrapper.shand itsphp/*.inifiles, for instance. -
Strip the Dockerfile down to the base image plus whatever this repository actually needs. The
apt-getblock, timezone lines,usermod --shell, and theCOPYof the zsh/commitizen config are all the Feature's job now. -
Repoint the lifecycle commands. Delete
postCreateCommandandpostAttachCommandif they only ran the shared scripts. If the repository has its own setup, keep apostCreateCommandfor just that part. -
Generalise the workspace paths (single-container frames only):
"workspaceMount": "source=${localWorkspaceFolder},target=/usr/share/${localWorkspaceFolderBasename},type=bind,consistency=cached", "workspaceFolder": "/usr/share/${localWorkspaceFolderBasename}"
Compose frames keep the literal path, because compose resolves its volumes independently and the two must agree.
-
Move the banner content from the deleted
env-info.shinto.devcontainer/env-info.conf. -
Trim the extension list. The Feature contributes the shared six; delete those from the repository's list and keep only the language-specific ones.
-
Rebuild, then check: the prompt renders,
devbase-env-inforeports the right versions,cz --versionworks,git pushauthenticates throughgh, and shell history survived the rebuild. -
Keep a
~/.zshrc.localif you had personal shell additions in the old.zshrc— the Feature's copy is overwritten on rebuild by design, and.zshrc.localis sourced at the end and never touched.
This repository's own devcontainer consumes devbase from the registry, exactly as a
consumer does — so this environment is a standing check that the published artifact
works. The working tree is exercised by the test suite instead:
pnpm features:test # default options + every scenario, real builds
pnpm features:test -- --filter minimal # one scenario, while iterating
pnpm features:lint # shellcheck + metadata validation
pnpm lint:workflows # actionlint over .github/workflows
pnpm lint # all of the above plus prettierpnpm lint runs the same checks CI does, from the same scripts, so a green run locally
means a green run in CI. lint:workflows is the one that cannot be left to CI alone: an
invalid workflow file never starts, so the actionlint job that would report it is one of
the jobs that does not run. It downloads a pinned, checksum-verified actionlint into
.cache/ on first use unless one is already on PATH.
pnpm features:test builds real containers straight from features/src/, so it is the
iteration loop — no publish, no copy, nothing to keep in sync. It needs a Docker daemon,
which is why the devcontainer includes docker-in-docker.
Occasionally you want your own environment built from the branch. There is a second
config for that — .devcontainer/local/devcontainer.json, offered by VS Code's config
picker as "working tree":
pnpm devbase:local # copy features/src/devbase -> .devcontainer/local/devbase
# then rebuild, choosing the "working tree" config
pnpm devbase:local:check # is the copy still current?
pnpm devbase:local:cleanRe-run pnpm devbase:local after each edit; forgetting is the one hazard of a copy, and
the reason this is the exception rather than the default. That config's
initializeCommand now runs the check for you and fails the build on a stale copy,
because the failure is otherwise unrecognisable as one: a copy predating dependsOn,
in a config that has stopped listing what dependsOn supplies, yields a container with
no node, npm, pnpm, gh or claude — while zsh and rtk are present, because
the old copy installs those itself. It reads as a broken registry rather than a missing
cp.
The copy is not laziness — the devcontainer CLI leaves no better option, and all three alternatives were measured:
| Reference | Result |
|---|---|
"../features/src/devbase" |
Refused: "Local file path parse error. Resolved path must be a child of the .devcontainer/ folder." |
symlink at .devcontainer/devbase |
Passes the path check, then fails the fetch step — the CLI needs a real directory |
real copy inside .devcontainer/ |
Works |
Copying it in from initializeCommand does not help either: features are resolved
before initializeCommand runs. The alternate config lives at .devcontainer/local/
precisely so its "./devbase" resolves inside .devcontainer/ and is accepted.
Until devbase has been published once there is nothing for the default config to pull.
Run the Publish features (manual) workflow once, or use pnpm devbase:local, before
building the default container on a fresh repository.
Layout:
Layout:
features/
├── src/devbase/
│ ├── devcontainer-feature.json # id, version, options, extensions, lifecycle hooks
│ ├── install.sh # build-time, as root, no workspace yet
│ ├── bin/
│ │ ├── devbase-post-create.sh # first create, as the user, workspace mounted
│ │ └── devbase-post-attach.sh # every attach
│ ├── lib/
│ │ ├── log.sh # logging + execute_with_indent
│ │ ├── setup.sh # the shared setup steps
│ │ └── env-info.sh # the banner (also installed as devbase-env-info)
│ └── config/{.zshrc,.czrc} # user shell configuration
└── test/devbase/
├── test.sh # default options
├── scenarios.json # non-default option combinations
├── minimal.sh # every option off
└── node_project.sh # alongside the Node feature
The build-time / create-time split is the thing to get right. install.sh runs as
root while the image builds, before the workspace is mounted — so it can only do
image-level work. Anything that reads the repository (its manifests, its
env-info.conf) must go in devbase-post-create.sh.
Two conventions the shell code follows throughout, both learned from real breakage:
- A missing prerequisite is reported and skipped, never fatal. A container that comes up without pnpm is fixable from a terminal; one that refuses to come up is not.
- zsh does not word-split unquoted parameter expansions.
for x in ${LIST}iterates once over the whole string under zsh while splitting correctly under bash. Route lists through thewords()helper, which uses a command substitution — zsh splits those.
Run the tests:
pnpm features:test # default options + every scenario
pnpm features:test -- --filter minimal # one scenario, while iterating
pnpm features:lint # shellcheck + metadata validation
pnpm lint # the above plus prettier and actionlintAssert on effects, not on log lines, and check a new assertion against a deliberately broken implementation before trusting it. A check that cannot fail is worse than no check: it reports SUCCESS and sends the next person looking somewhere else.
Releases are semantic-release, driven by commit type — nobody edits version in
devcontainer-feature.json by hand. On a push to main,
release.yml works out the next version from the
commits, writes it into the Feature metadata, commits and tags it, and publishes to
ghcr.io in the same run.
| Commit | Result for a consumer pinned to :1 |
|---|---|
fix(devbase): … |
patch — picked up on their next rebuild |
feat(devbase): … |
minor — picked up on their next rebuild |
feat(devbase): … + BREAKING CHANGE: |
major — not picked up until they change their pin |
ci / docs / chore / test / … |
nothing published |
Release and publish are deliberately one job: semantic-release commits the version bump
and pushes it over SSH with the release deploy key, and a deploy-key push triggers
workflows no more than the GITHUB_TOKEN push it replaced did — so a separate publish
workflow listening on push would never fire for exactly the commit that matters.
That deploy key is also why the job rewrites origin to the git@ form before
semantic-release runs. The default branch is protected by a ruleset whose only bypass
actor is DeployKey, and this job pushes to it twice — the release commit, and the
regenerated feature documentation at the end. Both have to be the key; an HTTPS push
with GITHUB_TOKEN has no bypass and would be rejected. The private half lives in the
repository secret RTLDEV_MW_CI_SSH_KEY, and the public half is the repository's single
write-enabled deploy key, titled semantic-release. A deploy key reaches exactly one
repository, which is the point: a leak costs this repository rather than everything a
personal access token can reach.
Publishing is idempotent (a version already in the registry is skipped, not overwritten),
which is why the publish step needs no guard, and why
publish-features.yml exists as a manual
escape hatch for the first publish and for re-publishing.
The first publish needs the package made public — ghcr.io packages default to
private, and a private Feature fails every consumer's build with a 401. Set it under
this repository's Packages → the devbase package → Package settings → Change
visibility.
What changed: devbase no longer declares
ghcr.io/anthropics/devcontainer-features/claude-code:1 in dependsOn, so a container
built from 2.x has no claude on PATH unless the repository asks for it. (RSRMID-3053)
Why: the Claude Code VS Code extension ships and runs its own runtime, at
resources/native-binary/claude inside the extension directory — verified by process
list in a running container, not inferred. The npm-installed global was therefore a second
copy of the same thing, and one that could not update itself: the claude-code feature
installs as root into a global npm tree the node feature makes vscode:nvm, so the package
directory lands root-owned at mode 755 and claude update fails for the remote user until
someone runs sudo chown -R vscode:nvm "$(npm root -g)/@anthropic-ai" by hand.
What you have to do. Because consumers pin devbase:1, 2.0.0 reaches nobody on a
rebuild — the upgrade is deliberate, in two steps:
- Change the reference in
.devcontainer/devcontainer.jsonfromdevbase:1todevbase:2. - If the repository commits a
devcontainer-lock.json, re-resolve it. Neitherdevcontainer upgradenor Dependabot will do this for you — see Keeping consumers up to date for why, and for the commands.
What you do not lose. gh, node at lts, pnpm and the RTK binary are all
unchanged, as are the anthropic.claude-code and anthropic.claude-vscode VS Code
extensions — which are now the only Claude Code in the container. RTK in particular still
matters: its PreToolUse hook lives in the bind-mounted, host-shared
~/.claude/settings.json and fires under the extension's runtime exactly as it did under
the CLI's, so a container without the binary still exits 127 on every Bash call.
If you actually want the CLI in a container, list the feature yourself:
"features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
"ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:2": {}
}That is a deliberate, per-repository choice rather than something every image carries. Note it brings the ownership problem above back with it.
A consumer with no devcontainer-lock.json needs nothing. Every rebuild re-resolves
devbase:2 to the newest 2.x, so a release reaches them by rebuilding — a minor or
patch release, that is; a major changes the tag and is the deliberate step described in
Migrating to 2.0.0. That is the
propagation story the copied-files approach never had — and for most consumers it is the
whole story.
A consumer that commits a lock has to move the pin deliberately, and neither of the two mechanisms you would expect does it for them:
devcontainer upgradeonly fills in missing entries. An entry that still satisfies its reference is re-emitted unchanged, and1.2.0satisfies:1just as well as1.4.0does, so the command is a no-op for exactly the case you want it for.- Dependabot's
devcontainersecosystem bumps version references indevcontainer.json. A frame that references a moving major tag such asdevbase:2has no reference to bump, so no PR is ever raised. This repository has that ecosystem configured and has never received a devcontainers PR, while receiving them forgithub-actionsandnpm— the updater works, it simply has nothing to say about:1.
The result is a lock that silently stays put: this repository's own pinned 1.2.0 through
four releases. To move it, drop the entry and re-resolve:
jq 'del(.features["ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:2"])' \
.devcontainer/devcontainer-lock.json > /tmp/lock && mv /tmp/lock .devcontainer/devcontainer-lock.json
npx devcontainer upgrade --workspace-folder .
npx prettier --write .devcontainer/devcontainer-lock.jsonDeleting the whole lock instead re-resolves every feature, which is fine but a wider diff.
The prettier line is not optional: the CLI writes the lock with no trailing newline and
pnpm lint rejects it.
If you want Dependabot to do this for you, reference a precise version —
devbase:1.4.0 rather than devbase:1 — and accept a PR per release instead of a moving
major. That is a real trade: the moving tag is what makes a patch reach every repository
without ceremony.
401 Unauthorized pulling the Feature. The ghcr.io package is still private —
see Publishing.
The banner does not appear on attach. It runs from postAttachCommand; check the
Dev Containers output panel. Run devbase-env-info by hand to separate "the banner is
broken" from "the hook did not fire". A syntax error in .devcontainer/env-info.conf is
the usual cause, and the banner deliberately exits 0 regardless so it can never block an
attach.
The prompt is plain, with no git segment. .zshrc is only fully active for an
interactive, non-CI shell, and the theme needs Oh My Zsh in the image (the
mcr.microsoft.com/devcontainers/base images have it). A base image without it still
gets a working shell, just unthemed.
Shell history did not survive a rebuild. historyPersistence needs the frame to bind
the host's ~/.zsh_history at /WSL_USER/.zsh_history; check the frame's mounts (or the
compose service's volumes). The step is skipped silently when the mount is absent, because
that is a legitimate configuration.
Bind that file, not the home directory it sits in. Binding the home directory also
satisfies the Feature — it only ever reads /WSL_USER/.zsh_history — but it hands the
container read-write reach over the host's SSH keys, credentials and every other
repository on the host, which is what RSRMID-3052 closed across this fleet. The frame
also needs the file to exist on the host before the bind:
"initializeCommand": {
"zsh-history": "touch ${localEnv:HOME}/.zsh_history"
},
"mounts": [
"source=${localEnv:HOME}/.zsh_history,target=/WSL_USER/.zsh_history,type=bind,consistency=cached"
]/WSL_USER/.zsh_history is a directory in post-create. The frame binds the file but
the host file did not exist when the container was created, so Docker created the missing
bind source as a directory — on the host, where it outlives the container. Add the
initializeCommand touch above, delete the stray directory on the host, and rebuild.
/WSL_USER is empty in post-create. Only a frame that still binds a whole directory
at /WSL_USER reaches this: the mount exists but its source path resolved to nothing, and
Docker created the missing source as an empty directory. On Windows hosts the usual cause
is the ${localEnv:HOME}${localEnv:USERPROFILE} idiom once used to name the host home —
it relies on exactly one of the two being set, and concatenates them into a nonexistent
path when both are. Moving to the single-file bind above removes the idiom along with the
mount that needed it.
pnpm: command not found in post-create. No Node toolchain in the container. Add
ghcr.io/devcontainers/features/node:2 — installsAfter then guarantees it is installed
before the Feature's post-create runs.
A change to the Feature had no effect. Either version was not bumped (a duplicate
version is skipped at publish), or the consumer has not rebuilt. Rebuild without cache to
be sure: Dev Containers: Rebuild Container Without Cache.
Personal shell customisations disappeared. Expected — the Feature owns ~/.zshrc and
overwrites it on rebuild, which is what stops the prompt drifting per repository. Put them
in ~/.zshrc.local.
- rtldev-middleware-template — the template repository new projects are created from; it ships the devcontainer frames that consume this Feature.
- rtldev-middleware-shareable-workflows — the reusable GitHub Actions workflows those repositories delegate CI to.
- Kai Schwarz — KaiSchwarz-cnic
- Asif Nawaz — AsifNawaz-cnic
MIT — see LICENSE.