The container installs the pnpm your package.json declares — either field:
"packageManager": "pnpm@11.24.0"These are the same two fields CI reads (pnpm/action-setup), read in the same order —
devEngines.packageManager first — and that is the whole point. Before this, the Node
dependency's pnpmVersion: latest decided it, so a container ran whichever pnpm was newest
on the day its image was built while the pipeline reviewing its lockfile ran the declared
one. Nothing announced the difference; a lockfile written by one and rejected by the other
is how you found out.
What counts as a declaration is narrow on purpose, and differs by field because the fields differ:
- in
devEngines.packageManager, a semver range such as^11.0.0— its normal shape. It is handed to npm, which resolves it to the newest matching release, exactly asaction-setupdoes. An exact version there works too. - in
packageManager, an exactpnpm@X.Y.Zonly, optionally with the+sha512-...integrity suffix, which is stripped before npm sees it. A range in that field is forbidden by specification, so honouring one would be guessing at what the repository failed to say. - not an entry naming npm or yarn, in either field. That repository said nothing about pnpm, and inventing an answer from it would be the Feature choosing rather than reading.
A range agrees on less than a pin, and that is the trade it makes: your container and your CI end up inside the same range rather than on an identical version, each resolving the newest match on the day it installs. In exchange, neither has to be bumped when a patch release lands. A container already inside the range is left alone rather than reinstalled.
Declare nothing and the pnpm already in the container stays put, untouched — latest is
installed only for a container that arrived with no pnpm at all. So adopting either field is
opt-in per repository, and a repository that has not yet adopted one sees no change.
No option switches this off. One would only give a repository a second place to say which pnpm it wants, and the container-versus-CI disagreement back again. To choose a version, declare it; to keep what you have, declare nothing.
npm remains the installation route, deliberately. corepack is not the alternative — Node
stopped bundling it at v25, and our engines policy spans ^24.15.0 || ^26.0.0, so it is
absent from half of that range. The standalone installer is a pipe-to-shell with nothing to
verify it against. npm is already present, and it is the route the Node feature itself used
to put pnpm there, so this replaces that global in place rather than shadowing it from a
second prefix and leaving PATH order to decide the winner.
The step runs before dependency installation, so pnpm install uses the version you
declared rather than the one the image happened to ship.
The neighbouring step reads engines.npm and raises npm to the major it names, because no
Node release bundles npm 12 and an explicit floor is the only way to get one. It acts on a
>= floor and nothing else:
"engines": { "npm": ">=12.0.0" }^12.0.0 is not a floor it can read, and a container whose npm stays behind because of it
now says so in the create log rather than skipping in silence.
This Feature sets LANG=C.UTF-8 for the whole container, via containerEnv in its
manifest. The devcontainer CLI turns that into an ENV in the built image, so it reaches
every process — not just interactive shells.
It is here rather than in each repository's Dockerfile because the problem is not
repository-specific. Our base images ship no LANG at all, which leaves the C library in
the C locale with an ASCII charmap, and every tool that reads a source file inherits
it. Any file with an em-dash in a comment — which is most of ours — then decodes wrong, in
whichever tool notices first. A per-repository containerEnv fixes one repository at a
time; this fixes the ones that have not hit it yet.
C.UTF-8 rather than en_US.UTF-8 on purpose:
- it is built into glibc, so no
localespackage and nolocale-genat build time, and it works on a minimal base image - its collation is codepoint order, the same as
C— sosort,[a-z]ranges and anything else a shell script relies on behave exactly as they did before. Only the charmap changes.
No option switches it off, deliberately. containerEnv is static JSON with no option
substitution — the same property that makes the gh login volume unconditional — so a flag could not
actually control it and would only be a lie. Override it in your own devcontainer.json
instead; the CLI emits the consumer's containerEnv after the feature layer, so yours
wins:
"containerEnv": { "LANG": "en_GB.UTF-8" }Do check the locale exists in your image before naming a generated one. setlocale falls
back to C in silence when it does not, which looks exactly like the bug this fixes.
The ghCredentialHelper step points git at gh auth git-credential, so git over HTTPS is
only as logged in as gh is. Before RSRMID-3096 the login lived in ~/.config/gh inside
the container and nothing persisted it: every rebuild was logged out, and every push failed
with could not read Username ... terminal prompts disabled until someone ran
gh auth login again.
The Feature now keeps the login in a per-container named volume:
containerEnv.GH_CONFIG_DIRis/var/lib/devbase/gh, which redirectsgh.mountsdeclares the named volumedevbase-gh-${devcontainerId}at that path. It lives on the container host, not in the image and not in the workspace, and no host path is involved.${devcontainerId}is stable for one devcontainer, so a rebuild reattaches the same volume while another repository gets its own.- The remote user's home varies by image, which is why the volume is not mounted at
~/.config/gh.
Lifecycle
- First create:
ghis logged out. Rungh auth loginonce. - Rebuild: the login is still there. Nothing to do.
- Every attach: a warning is printed, last, when
ghholds no login (skipped in CI). It reads the token locally and makes no network call, and it is independent ofenvInfoBanner. A logged-inghprints nothing. - Reset: remove the volume while the container is gone —
docker volume rm devbase-gh-<devcontainerId>— and rebuild.
Ownership. The volume has to be writable by the remote user, and post-create repairs
that on every create with sudo -n chown of the directory (the devcontainers base images
grant passwordless sudo). It is needed because the Dev Containers CLI remaps the remote user
to the host's UID when it differs from the image's (any Linux host whose UID is not 1000,
GitHub runners included) and re-owns only the home directory, so a directory install.sh
created at build time keeps the old owner and a fresh volume inherits it. Docker also seeds
ownership only into an empty volume, so a volume that already existed keeps its old owner.
Where sudo is missing or wants a password, post-create reports
GH_CONFIG_DIR ... could not be repaired and carries on; fix it with
sudo chown "$(id -u):$(id -g)" /var/lib/devbase/gh, or remove the volume and rebuild.
No option switches the volume off, and that includes ghCredentialHelper: false:
mounts and containerEnv are static JSON with no option substitution, so a flag could
not control them. The option only controls whether git is pointed at gh and whether the
attach warning is shown. To opt out of the redirect, set your own GH_CONFIG_DIR in the
repository's containerEnv; the CLI emits the consumer's containerEnv after the feature
layer, so yours wins.
"containerEnv": { "GH_CONFIG_DIR": "/home/vscode/.config/gh" }This Feature also contributes customizations.vscode.settings (shellcheck, the zsh
terminal profile, npm.packageManager, files.exclude); the full set is in
devcontainer-feature.json. Your own devcontainer.json wins wherever the
two name the same key — but for object-valued settings that is a replacement, not a
deep merge. VS Code does not merge object values across settings scopes.
files.exclude is the one to watch, because the Feature ships an entry in it:
"files.exclude": { "**/node_modules": true }If you declare your own files.exclude, restate the entries you want to keep:
"customizations": {
"vscode": {
"settings": {
"files.exclude": {
"**/node_modules": true, // restate, or you lose it
"**/vendor": true
}
}
}
}Omitting **/node_modules from that object un-hides the directory just as surely as
setting it to false — which is the supported way to opt out on purpose:
"files.exclude": { "**/node_modules": false }If your frame bind-mounts the host ~/.gitconfig — as the reference frame in this
repository does — it brings commit.gpgsign=true, gpg.format=ssh and a
user.signingkey naming a path under the host's ~/.ssh. That path does not exist in
the container, so every commit fails with:
error: Couldn't load public key /home/you/.ssh/your_key: No such file or directory?
fatal: failed to write commit object
sshCommitSigning repairs this at create time using the ssh-agent VS Code forwards: it
writes an inline key::<public key> user.signingkey into the workspace's git config,
so the private half never enters the container and no file is written to ~/.ssh — which
frames routinely mount from the host. The inline form resolves identically on the host, so
a bind-mounted .git/config stays correct on both sides.
It only ever repairs signing you already configured. It will not switch signing on, will
not touch a key file that exists, and will not overwrite an inline key. Without a forwarded
agent — CI, a plain docker run — it reports and skips rather than clearing your
configuration.
With more than one key in the agent it picks the one whose comment names your configured
key, and otherwise skips rather than guessing: an authentication key signs a commit
perfectly well and GitHub still rejects the signature, which is a confusing failure to
inherit from a container. Set user.signingkey to a key::ssh-ed25519 AAAA... literal
yourself if you want a specific key chosen.