Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .devcontainer/sandbox-pnpm/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
// Build args are read from the environment without defaults, so build-checks.yml
// supplies the values it just built the image with and the Dev Container CLI
// reuses those layers. A missing value fails the build instead of silently
// selecting a different version; AGENTS.md documents how to export them by hand.
"name": "sandbox-pnpm",
"build": {
"dockerfile": "../../pnpm/Dockerfile",
"args": {
"DEBIAN_TAG": "${localEnv:DEBIAN_TAG}",
"PNPM_VERSION": "${localEnv:PNPM_VERSION}"
}
},
"runArgs": [
"--cap-drop=ALL",
"--security-opt=no-new-privileges"
],
"remoteUser": "dev"
}
9 changes: 9 additions & 0 deletions .github/workflows/build-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ on:
- '*/tofu-ls-checksums.txt'
- '*/rustup-init-*.sha256'
- '*/uv-*.sha256'
- '*/sigstore-trusted-root.json'
- '*/githubcli-archive-keyring.gpg'
- .devcontainer/feature-**
- .devcontainer/sandbox-**
- scripts/build-config.sh
Expand All @@ -29,6 +31,8 @@ on:
- '*/tofu-ls-checksums.txt'
- '*/rustup-init-*.sha256'
- '*/uv-*.sha256'
- '*/sigstore-trusted-root.json'
- '*/githubcli-archive-keyring.gpg'
- .devcontainer/feature-**
- .devcontainer/sandbox-**
- scripts/build-config.sh
Expand Down Expand Up @@ -283,6 +287,11 @@ jobs:
load: true
tags: ${{ matrix.image }}:${{ matrix.variant }}
build-args: ${{ steps.read.outputs.image_args }}
# Read by images whose build queries the GitHub API; an anonymous
# request is capped at 60 per hour per IP. Mounted at build time only,
# so it never lands in a layer.
secrets: |
github_token=${{ secrets.GITHUB_TOKEN }}
# Cache scopes are arch-suffixed to match publish-image.yml so PR
# builds and release dry-runs share the same cache entries.
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.variant }}-${{ matrix.arch }}
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ jobs:
push: false
outputs: type=oci,dest=/tmp/image,tar=false
build-args: ${{ needs.prepare.outputs.build_args }}
# Read by images whose build queries the GitHub API; an anonymous
# request is capped at 60 per hour per IP. Mounted at build time only,
# so it never lands in a layer, the SBOM, or the provenance.
secrets: |
github_token=${{ secrets.GITHUB_TOKEN }}
labels: ${{ needs.prepare.outputs.labels }}
annotations: ${{ needs.prepare.outputs.annotations }}
sbom: true
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
[![Attestation Checks](https://github.com/bare-devcontainer/images/actions/workflows/attest-check.yml/badge.svg?branch=main)](https://github.com/bare-devcontainer/images/actions/workflows/attest-check.yml)

Minimal, multi-arch Dev Container images: a small Debian base, plus one image per stack for
Go, Node.js, Deno, Bun, Python, Rust, Zig, Terraform, and OpenTofu, and one with mise for
polyglot projects. Each carries only what its target stack needs, is built from a small set
of verified upstreams, and ships with SLSA provenance, a GitHub artifact attestation, and an
SBOM.
Go, Node.js, Deno, Bun, Python, Rust, Zig, Terraform, and OpenTofu, one with pnpm for Node.js
projects that pin their own runtime, and one with mise for polyglot projects. Each carries
only what its target stack needs, is built from a small set of verified upstreams, and ships
with SLSA provenance, a GitHub artifact attestation, and an SBOM.

## Quick start

Expand Down Expand Up @@ -60,9 +60,9 @@ README says otherwise:
extensions compile, but a library a project links against brings its own `-dev` package.
- **No editors, shells, or CLI tooling beyond the basics.** `bash` and `vim-tiny` are present;
editors, alternative shells, cloud CLIs, and linters are not.
- **No language toolchain in the version-manager images.** `mise`, `rustup`, and `uv` install
the version the project declares rather than one baked into the image. The `Not installed`
section of each image's README states exactly what is left out.
- **No language toolchain in the version-manager images.** `mise`, `pnpm`, `rustup`, and `uv`
install the version the project declares rather than one baked into the image. The
`Not installed` section of each image's README states exactly what is left out.

There are two ways to add what a project needs on top:

Expand Down Expand Up @@ -97,6 +97,7 @@ There are two ways to add what a project needs on top:
| [mise](mise/README.md) | `ghcr.io/bare-devcontainer/mise` | Polyglot projects that pin their own runtimes |
| [node](node/README.md) | `ghcr.io/bare-devcontainer/node` | Node.js, with Corepack instead of npm |
| [opentofu](opentofu/README.md) | `ghcr.io/bare-devcontainer/opentofu` | Infrastructure as code with OpenTofu |
| [pnpm](pnpm/README.md) | `ghcr.io/bare-devcontainer/pnpm` | Node.js, with the runtime version managed by pnpm |
| [rustup](rustup/README.md) | `ghcr.io/bare-devcontainer/rustup` | Rust, with the toolchain chosen by the project |
| [terraform](terraform/README.md) | `ghcr.io/bare-devcontainer/terraform` | Infrastructure as code with Terraform |
| [uv](uv/README.md) | `ghcr.io/bare-devcontainer/uv` | Python, with the interpreter managed by uv |
Expand Down
109 changes: 109 additions & 0 deletions pnpm/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# syntax=docker/dockerfile:1@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32
# check=skip=InvalidDefaultArgInFrom;error=true
# ARGs are intentionally required to prevent accidental builds with the wrong base image.

ARG DEBIAN_TAG

FROM ghcr.io/bare-devcontainer/debian:${DEBIAN_TAG} AS builder

ARG PNPM_VERSION
ARG TARGETARCH

# hadolint ignore=DL3002 # builder stage is a throwaway; never shipped or run
USER root

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

# pnpm publishes no detached signature and no checksum file on a host separate
# from the release itself, so a downloaded checksum would prove only that the
# release host served its own artifact consistently. What pnpm does publish is
# a build provenance attestation: a Sigstore bundle whose certificate binds the
# archive's digest to the workflow that produced it
# (pnpm/pnpm .github/workflows/release.yml at the version tag). Verifying it
# against the trusted root committed to this repository establishes who built
# the archive, independently of who served it.
#
# `gh` is the reference verifier for these attestations, and this repository
# already uses it to verify its own images (scripts/verify-image.sh). Debian
# ships gh 2.46, which predates the `gh attestation` command, so it comes from
# GitHub's own apt repository, verified with the committed keyring.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
--mount=type=bind,source=githubcli-archive-keyring.gpg,target=/tmp/githubcli-archive-keyring.gpg,ro \
install -D -m 644 /tmp/githubcli-archive-keyring.gpg /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=${TARGETARCH} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get update \
&& apt-get --no-install-recommends install -y gh

# The attestation is fetched from the GitHub API. Anonymous requests are capped
# at 60 per hour per IP, so the workflows pass a token as a build secret; a
# local build without one still works. The secret is mounted for this
# instruction only and never becomes part of a layer.
RUN --mount=type=secret,id=github_token,required=false \
--mount=type=bind,source=sigstore-trusted-root.json,target=/tmp/sigstore-trusted-root.json,ro \
case "${TARGETARCH}" in \
amd64) PNPM_ARCH="x64" ;; \
arm64) PNPM_ARCH="arm64" ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& PNPM_TMPDIR="$(mktemp -d)" \
# gh reads the trusted root as JSON Lines; the published file is pretty-printed.
&& jq -c . /tmp/sigstore-trusted-root.json > "${PNPM_TMPDIR}/trusted-root.jsonl" \
# Download the release archive, which holds the pnpm binary and the dist/
# directory next to it. This is the same artifact pnpm's own container
# image installs, and the layout pnpm resolves node-gyp from.
&& PNPM_TARBALL="pnpm-linux-${PNPM_ARCH}.tar.gz" \
&& wget -q -T 30 -t 3 -P "${PNPM_TMPDIR}" "https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/${PNPM_TARBALL}" \
&& PNPM_DIGEST="$(sha256sum "${PNPM_TMPDIR}/${PNPM_TARBALL}" | cut -d' ' -f1)" \
# Fetch the provenance attestation for exactly that digest
&& GH_API_ARGS=(--header="Accept: application/vnd.github+json" --header="X-GitHub-Api-Version: 2022-11-28") \
&& if [ -s /run/secrets/github_token ]; then \
GH_API_ARGS+=(--header="Authorization: Bearer $(cat /run/secrets/github_token)"); \
fi \
&& wget -q -T 30 -t 3 -O "${PNPM_TMPDIR}/attestations.json" "${GH_API_ARGS[@]}" \
"https://api.github.com/repos/pnpm/pnpm/attestations/sha256:${PNPM_DIGEST}?predicate_type=https%3A%2F%2Fslsa.dev%2Fprovenance%2Fv1" \
&& jq -c '.attestations[].bundle' "${PNPM_TMPDIR}/attestations.json" > "${PNPM_TMPDIR}/bundles.jsonl" \
&& test -s "${PNPM_TMPDIR}/bundles.jsonl" \
# Verify the attestation offline against the committed trusted root: the
# signing identity must be pnpm's release workflow, at the tag of the
# version being installed. --deny-self-hosted-runners is deliberately not
# used: pnpm builds its release binaries on Blacksmith runners, which the
# OIDC claim reports as self-hosted, so the flag would reject every pnpm
# release rather than catch anything.
&& gh attestation verify "${PNPM_TMPDIR}/${PNPM_TARBALL}" \
--bundle "${PNPM_TMPDIR}/bundles.jsonl" \
--custom-trusted-root "${PNPM_TMPDIR}/trusted-root.jsonl" \
--repo pnpm/pnpm \
--signer-workflow "pnpm/pnpm/.github/workflows/release.yml" \
--source-ref "refs/tags/v${PNPM_VERSION}" \
# Install pnpm the way pnpm's own container image does
&& mkdir -p /opt/pnpm \
&& tar xzf "${PNPM_TMPDIR}/${PNPM_TARBALL}" -C /opt/pnpm \
&& chmod 755 /opt/pnpm/pnpm \
&& rm -rf "${PNPM_TMPDIR}" \
&& test "$(/opt/pnpm/pnpm --version)" = "${PNPM_VERSION}" \
&& /opt/pnpm/pnpm completion bash > /usr/share/bash-completion/completions/pnpm

FROM ghcr.io/bare-devcontainer/debian:${DEBIAN_TAG}

COPY --from=builder /opt/pnpm /opt/pnpm
COPY --from=builder /usr/share/bash-completion/completions/pnpm /usr/share/bash-completion/completions/pnpm

USER root

RUN ln -s /opt/pnpm/pnpm /usr/local/bin/pnpm

# pnpm keeps the store, the runtimes it downloads, and the bins of globally
# installed packages under PNPM_HOME, and refuses a global install when that
# directory is not writable or its bin/ is not on PATH. It therefore belongs to
# the user rather than to the image; the path is pnpm's own default on Linux.
ENV PNPM_HOME=/home/dev/.local/share/pnpm
ENV PATH=/home/dev/.local/share/pnpm/bin:${PATH}

USER dev

RUN mkdir -p \
/home/dev/.cache/pnpm \
/home/dev/.local/share/pnpm/bin
105 changes: 105 additions & 0 deletions pnpm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# pnpm

Dev container image with [pnpm](https://pnpm.io/) installed, built on the [debian](../debian)
base image. pnpm installs the Node.js version the project asks for, so the runtime is chosen by
the project rather than baked into the image.

## Image

```
ghcr.io/bare-devcontainer/pnpm:<tag>
```

Reference it from `.devcontainer/devcontainer.json`, pinning the digest as well as the tag:

```json
{
"image": "ghcr.io/bare-devcontainer/pnpm:12@sha256:<digest>"
}
```

## Dev Container Template

A ready-to-use Dev Container template for this image is available at
[bare-devcontainer/templates](https://github.com/bare-devcontainer/templates/tree/main/src/pnpm).
It provides the recommended configuration for this image, including security hardening and
volume mounts that persist cache directories for faster rebuilds.

## Tags

<!-- tags:begin -->
| Tags | Debian variant |
|------|----------------|
| `12.3.1-trixie`, `12-trixie`, `12.3.1`, `12`, `trixie` | trixie |
| `12.3.1-bookworm`, `12-bookworm`, `bookworm` | bookworm |

Tags are also published with a date suffix on each build (e.g., `12.3.1-trixie-<YYYYMMDD>`).
<!-- tags:end -->

The version in these tags is the version of pnpm itself, not of any Node.js runtime.

## Installed software

Everything from the [debian](../debian) base image, plus:

- [pnpm](https://pnpm.io/) (`pnpm`), with bash completions installed

pnpm is installed under `/opt/pnpm` with a `/usr/local/bin/pnpm` symlink, the layout of pnpm's
own [`ghcr.io/pnpm/pnpm`](https://pnpm.io/docker#official-pnpm-base-image) image. `PNPM_HOME` is
set to `~/.local/share/pnpm` and its `bin` directory is on `PATH`, so runtimes and globally
installed packages resolve without further setup.

## Not installed

- **No Node.js.** pnpm downloads and manages the runtime the project declares in
[`devEngines.runtime`](https://pnpm.io/package_json#devenginesruntime), so the version in use is
the one the project asks for. See [Working with runtimes](#working-with-runtimes) below.
- **No `npm`, `npx`, or Corepack.** pnpm covers those workflows, and installing a Node.js runtime
with pnpm deliberately leaves the bundled `npm` unextracted. Run `pnpm add -g npm` if a project
needs it. For a project whose package manager is not pnpm, use the [node](../node) image instead.
- **No global JavaScript tooling.** Linters, formatters, and test runners are left to the
project's own dependencies.

## Working with runtimes

Declare the runtime in the project's `package.json` and pnpm installs it on first use:

```json
{
"devEngines": {
"runtime": { "name": "node", "version": "^24.0.0", "onFail": "download" }
}
}
```

Inside that project a bare `node` runs the pinned version, because pnpm's global `node` is a shim
that dispatches to what the project asks for. Outside any project it runs the globally installed
version, which is set with [`pnpm runtime`](https://pnpm.io/cli/runtime):

```sh
pnpm runtime set node lts -g
```

To install the project's runtime and dependencies when the container is created rather than on
first use, run `pnpm install` from a `postCreateCommand`.

Two directories are worth persisting across container rebuilds as volumes:

- `~/.local/share/pnpm` — the store, the managed runtimes, and the bins of globally installed
packages. All of it is re-downloaded on every rebuild unless this directory survives.
- `~/.cache/pnpm` — the metadata cache.

## Supply chain

pnpm is downloaded from [GitHub Releases](https://github.com/pnpm/pnpm/releases), the same archive
pnpm's own container image installs. pnpm publishes no detached signature and no checksum on a host
separate from the release, so the archive is verified against its
[build provenance attestation](https://github.com/pnpm/pnpm/blob/main/.github/workflows/release.yml)
instead: `gh attestation verify` checks that the archive's digest was signed by pnpm's release
workflow at the tag being installed. The Sigstore trusted root
(`pnpm/sigstore-trusted-root.json`) is committed to this repository and kept in sync by
`.github/workflows/update-material.yml`, so the attestation is checked against keys reviewed here
rather than keys fetched at build time.

Note that this covers the pnpm binary only. Runtimes and packages that pnpm installs at runtime are
fetched from their own upstreams under pnpm's own verification, outside this image's build pipeline.
43 changes: 43 additions & 0 deletions pnpm/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
description: "Dev Container image for Node.js development with pnpm, built on Debian"
materials:
- path: sigstore-trusted-root.json
url: https://raw.githubusercontent.com/sigstore/root-signing/main/targets/trusted_root.json
description: >-
Sigstore public good instance trusted root from sigstore/root-signing,
holding the Fulcio, Rekor, certificate transparency, and timestamp
authority keys. This committed file is the trust anchor the Dockerfile
verifies pnpm's build provenance attestation against, so the attestation
is checked against keys reviewed here rather than keys fetched at build
time.
- path: githubcli-archive-keyring.gpg
url: https://cli.github.com/packages/githubcli-archive-keyring.gpg
description: >-
GitHub CLI apt repository signing key, matching the fingerprints
published in cli/cli's docs/install_linux.md. Used only in the builder
stage, to install the `gh` that verifies the attestation; Debian's own
`gh` package predates the `gh attestation` command.
variants:
- variant: "12-trixie"
tags:
# renovate: datasource=github-releases depName=pnpm/pnpm versioning=semver extractVersion=^v(?<version>.+)
- "12.3.1-trixie"
- "12-trixie"
# renovate: datasource=github-releases depName=pnpm/pnpm versioning=semver extractVersion=^v(?<version>.+)
- "12.3.1"
- "12"
- "trixie"
debian_variant: "trixie"
build_args:
# renovate: datasource=github-releases depName=pnpm/pnpm versioning=semver extractVersion=^v(?<version>.+)
PNPM_VERSION: "12.3.1"

- variant: "12-bookworm"
tags:
# renovate: datasource=github-releases depName=pnpm/pnpm versioning=semver extractVersion=^v(?<version>.+)
- "12.3.1-bookworm"
- "12-bookworm"
- "bookworm"
debian_variant: "bookworm"
build_args:
# renovate: datasource=github-releases depName=pnpm/pnpm versioning=semver extractVersion=^v(?<version>.+)
PNPM_VERSION: "12.3.1"
Binary file added pnpm/githubcli-archive-keyring.gpg
Binary file not shown.
Loading