Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0cf8f10
Add oven Containerfile with build/test/validate/scan tools
bschwedler Aug 6, 2026
4a250cc
Fix goss/hadolint version resolution and checksum verification
bschwedler Aug 6, 2026
91e2e85
Apply all four fixes from code review
bschwedler Aug 6, 2026
ec5b183
Restore latest defaults and fix goss tag resolution
bschwedler Aug 6, 2026
2b1f99c
Add just targets for the bakery oven
bschwedler Aug 6, 2026
543f620
Fix: make oven recipe -t flag conditional on TTY
bschwedler Aug 6, 2026
a6308d8
Fix: use git-common-dir to find correct parent for mounts
bschwedler Aug 6, 2026
32bd7f0
Fix git safe.directory configuration in entrypoint.sh
bschwedler Aug 6, 2026
85861b5
Fix container to run as host user instead of root
bschwedler Aug 6, 2026
4c16a47
Fix: use positional arguments for proper shell arg forwarding
bschwedler Aug 6, 2026
c0efa64
Fix container caching and version handling
bschwedler Aug 6, 2026
377823a
Fix relative path resolution in bakery wrapper
bschwedler Aug 6, 2026
023546f
Add DGOSS_TEMP_DIR environment variable for goss testing
bschwedler Aug 6, 2026
8238970
Final review: HOME persistence, filesystem consolidation, hadolint fa…
bschwedler Aug 6, 2026
7a9acef
Fix hadolint checksum verification for older versions
bschwedler Aug 6, 2026
7fa275b
Use jq for goss tag_name parsing in oven Containerfile
bschwedler Aug 7, 2026
77f0a8f
Install just in the oven image
bschwedler Aug 7, 2026
dd55307
Install oras in the oven image
bschwedler Aug 7, 2026
b519261
Run the oven container with --network host
bschwedler Aug 7, 2026
b8fa38d
Add oven-setup-qemu recipe for cross-arch builds
bschwedler Aug 7, 2026
00089a5
Document QEMU setup instead of scripting it via a container
bschwedler Aug 7, 2026
c9129ee
Document oven multi-arch QEMU setup in CONTRIBUTING.md
bschwedler Aug 7, 2026
d354de8
Fix oven recipe fragility and drop duplicated QEMU note
bschwedler Aug 7, 2026
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
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ Do not backport cosmetic changes, new feature additions, or non-security depende

- **Version format mismatch.** Product repos dispatch with raw git-describe versions (e.g., `v2026.03.0-473-g072bb6fd1f`). Bakery normalizes these to semver-with-metadata (e.g., `2026.03.0-dev+473-g072bb6fd1f`). If `bakery ci matrix` produces an empty matrix after a dispatch, the formats did not align. The shared workflows strip a leading `v` automatically. Check the rest of the version string against bakery's normalization.

- **Multi-arch builds inside the oven need host-level QEMU setup.** Building
or testing `linux/arm64` targets from an amd64 host (or vice versa) needs
QEMU emulation registered in the host kernel's `binfmt_misc` table. This is
outside the oven image's control — it only relays build instructions to the
host's own Docker daemon over the DooD socket. `binfmt_misc` is global to
the host kernel, not namespaced per-container, so installing it on the host
also covers builds run from inside the oven. On Ubuntu:

```bash
sudo apt-get install qemu-user-binfmt
```

`qemu-user-binfmt` integrates with `systemd-binfmt.service`, so
registration survives a reboot without further setup.

## Change-aware builds

`bakery ci matrix` supports `--base-ref <ref>` and `--changed-files-from <file|->` to emit
Expand Down
48 changes: 48 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
#!/usr/bin/env just --justfile

# required by the `oven` recipe below: passes *ARGS to the recipe's shell as
# real positional parameters ("$@") instead of `just`'s default plain-text
# substitution. Plain {{ ARGS }} joins arguments with unquoted spaces, so a
# single argument containing a space (e.g. `bash -lc "docker ps"`) silently
# splits into two words before any shell sees it — this passed CI-looking
# (exit 0) but silently wrong output through two separate rounds before it
# was caught. Do not revert this to {{ ARGS }}.
set positional-arguments := true

################
# Variables
CWD := justfile_directory()
Expand All @@ -26,3 +35,42 @@ _python-executable executable:
_setup-pre-commit:
pre-commit --version || just _python-executable pre-commit
pre-commit install --install-hooks

################
# Oven commands (local build/test/validate/scan environment)

# Build the bakery-oven image
oven-build:
docker build -t bakery-oven -f "{{ CWD }}/oven/Containerfile" "{{ CWD }}/oven"

# Multi-arch (linux/arm64 from amd64, or vice versa) needs one-time host
# QEMU setup — see CONTRIBUTING.md.

# Build (if stale) and drop into the oven with sibling repos mounted
#
# images-shared is worked on via git worktrees, so {{ CWD }} is never the
# main checkout — sibling product repos live next to *that*, not next to a
# worktree path, and a worktree's own .git is a pointer file into the main
# checkout's real .git. Resolving the mount root via git-common-dir + two
# dirnames gets the right directory either way (plain checkout or
# worktree). It must be mounted at an identical host/container path because
# dgoss and `bakery build` construct bind-mount arguments that only the
# *host* daemon (on the other end of the socket) ever resolves.
oven *ARGS: oven-build
git_common_dir="$(git -C "{{ CWD }}" rev-parse --path-format=absolute --git-common-dir)"; \
: "${git_common_dir:?failed to resolve git-common-dir for this checkout}"; \
mount_root="$(dirname "$(dirname "$git_common_dir")")"; \
docker_gid="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock)"; \
: "${docker_gid:?failed to read the group ID of /var/run/docker.sock}"; \
mkdir -p "$HOME/.cache/bakery-oven/home" || exit 1; \
docker run --rm -i $(test -t 0 && echo -t) \
--user "$(id -u):$(id -g)" \
--group-add "$docker_gid" \
--network host \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$mount_root:$mount_root" \
-v "$HOME/.cache/bakery-oven:/opt/oven/state" \
-e BAKERY_REPO_PATH="{{ CWD }}" \
-e DGOSS_TEMP_DIR="$mount_root" \
-w "{{ CWD }}" \
bakery-oven "$@"
237 changes: 237 additions & 0 deletions oven/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# syntax=docker/dockerfile:1
#
# "The oven": bundles the tools needed to build, test, validate, and scan a
# Posit product image locally via Docker-outside-of-Docker (DooD) — the
# container never runs its own daemon, it talks to the host's via a mounted
# /var/run/docker.sock.
#
# Runtime contract (enforced by the `oven`/`oven-build` just recipes, not by
# this image — there is no USER instruction):
# --user "$(id -u):$(id -g)" match the host user, so the
# container never writes
# root-owned files into a
# mounted checkout
# --group-add <docker.sock's group ID> grants socket access
# without needing to know the
# host's docker GID at build
# time
# --network host DooD shares only the daemon's
# *socket*, not its network
# namespace — without this,
# "localhost" here and
# "localhost" on the host-daemon
# side are different network
# stacks, so a container's
# published port (e.g. a local
# test registry) isn't reachable
# via localhost from inside here
# -v /var/run/docker.sock:/var/run/docker.sock DooD
# -v <dir>:<dir> (identical host/container path) required for any bind
# mount: dgoss/bakery
# generate mount arguments
# the *host* daemon resolves
# against its own filesystem
# -e BAKERY_REPO_PATH=<images-shared checkout> entrypoint syncs
# posit-bakery from here
# -v <persistent host dir>:/opt/oven/state survives across --rm runs;
# without it every
# invocation re-downloads
# Python, wheels, and the
# trivy DB from scratch
# Invoking this image any other way (e.g. `docker run bakery-oven` with none
# of the above) runs as root and will write root-owned files into whatever
# gets mounted.

FROM ubuntu:24.04

ARG TARGETARCH
ARG GOSS_VERSION=latest
ARG HADOLINT_VERSION=latest
ARG JUST_VERSION=1.58.0
ARG ORAS_VERSION=1.3.3
ARG TRIVY_VERSION=0.73.0

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

ENV DEBIAN_FRONTEND=noninteractive
# The container runs as an arbitrary host UID (see runtime contract above)
# with no matching /etc/passwd entry, so tools that key off $HOME need an
# explicit value. Pointing it at /opt/oven/state (persistent, see below)
# rather than /tmp means uv's Python/package cache, trivy's vulnerability
# DB, and wizcli's auth token all survive across separate `--rm` runs
# instead of being re-fetched every time.
ENV HOME=/opt/oven/state/home
# The entrypoint writes a `bakery` wrapper here at container start
# (BAKERY_REPO_PATH isn't known until then) — a non-root UID can't write to
# /usr/local/bin, so this needs its own world-writable directory.
ENV PATH="/opt/oven/bin:${PATH}"
# uv must never touch the mounted checkout's own posit-bakery/.venv — that
# file is shared with the host's own direct `uv run` usage, and a
# container-only interpreter path recorded there breaks it (see CLAUDE.md
# for the host workflow). Point uv entirely at paths under /opt/oven/state
# instead: the venv lives on the same filesystem as the cache (avoids uv's
# cross-device "falling back to full copy" warning, lets hardlinks work),
# and both persist across runs via the mount described above.
ENV UV_PROJECT_ENVIRONMENT=/opt/oven/state/venv
ENV UV_CACHE_DIR=/opt/oven/state/cache
ENV UV_PYTHON_INSTALL_DIR=/opt/oven/state/python

RUN mkdir -p /opt/oven/bin /opt/oven/state \
&& chmod 1777 /opt/oven/bin /opt/oven/state

# Base utilities
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
git \
jq \
&& rm -rf /var/lib/apt/lists/*

# Docker CLI + buildx plugin (CLI only — no daemon/engine package)
RUN install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& . /etc/os-release \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
docker-ce-cli \
docker-buildx-plugin \
&& rm -rf /var/lib/apt/lists/*

# uv + uvx (Python itself is provisioned on demand by `uv sync`)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

# just (github.com/casey/just) — for parity with the host dev workflow
# (CLAUDE.md's `just test`/`just test-all`/`just setup`), not only the
# `bakery` CLI the entrypoint wraps directly.
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) just_arch="x86_64" ;; \
arm64) just_arch="aarch64" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
dest="/tmp/just"; mkdir -p "$dest"; cd "$dest"; \
just_num="${JUST_VERSION#v}"; \
asset="just-${just_num}-${just_arch}-unknown-linux-musl.tar.gz"; \
base_url="https://github.com/casey/just/releases/download/${just_num}"; \
curl -fsSL "${base_url}/${asset}" -o "${asset}"; \
curl -fsSL "${base_url}/SHA256SUMS" -o SHA256SUMS; \
sha256sum --ignore-missing -c SHA256SUMS; \
tar -xzf "${asset}" just; \
install -m 0755 just /usr/local/bin/just; \
cd /; rm -rf "$dest"

# goss + dgoss (github.com/goss-org/goss) — mirrors setup-goss/action.yml's
# current logic: goss's v0.4.10+ releases ship a versioned tarball plus one
# combined SHA256SUMS file, so a "latest" version has to be resolved to a
# concrete tag up front to build the asset filename. dgoss's own release
# filenames are unversioned and unaffected, so it keeps the older bare
# binary + per-file .sha256 scheme.
RUN set -eux; \
dest="/tmp/goss"; mkdir -p "$dest"; cd "$dest"; \
case "${TARGETARCH}" in \
amd64) goss_arch="x86_64" ;; \
arm64) goss_arch="arm64" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
if [ "${GOSS_VERSION}" = "latest" ]; then \
goss_tag=$(curl -fsSL https://api.github.com/repos/goss-org/goss/releases/latest | jq '.tag_name' -r); \
else \
goss_tag="v${GOSS_VERSION#v}"; \
fi; \
goss_num="${goss_tag#v}"; \
release_url="https://github.com/goss-org/goss/releases/download/${goss_tag}"; \
asset="goss_${goss_num}_linux_${goss_arch}.tar.gz"; \
curl -fsSL "${release_url}/${asset}" -o "${asset}"; \
curl -fsSL "${release_url}/goss_${goss_num}_SHA256SUMS" -o SHA256SUMS; \
sha256sum --ignore-missing -c SHA256SUMS; \
tar -xzf "${asset}" goss; \
install -m 0755 goss /usr/local/bin/goss; \
curl -fsSL "${release_url}/dgoss" -o dgoss; \
curl -fsSL "${release_url}/dgoss.sha256" -o dgoss.sha256; \
sha256sum -c dgoss.sha256; \
install -m 0755 dgoss /usr/local/bin/dgoss; \
cd /; rm -rf "$dest"

# hadolint (github.com/hadolint/hadolint) — mirrors setup-hadolint/action.yml's
# current logic (v2.15.0+ ships one checksums.sha256 covering every binary;
# older releases ship a per-binary "${binary}.sha256" file instead), plus a
# fix that action doesn't have: GitHub's release-download URLs resolve
# asset names case-insensitively, so probing "hadolint-linux-${arch}" first
# and falling back to "hadolint-Linux-${arch}" on failure never actually
# falls back — the lowercase request succeeds anyway and downloads the
# real (capitalized-named) asset under the wrong local filename. Instead,
# extract the true filename from the checksum file's own content (which is
# authoritative) and use that for both the download and the local name.
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) hadolint_arch="x86_64" ;; \
arm64) hadolint_arch="arm64" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
dest="/tmp/hadolint"; mkdir -p "$dest"; cd "$dest"; \
if [ "${HADOLINT_VERSION}" = "latest" ]; then \
release_url="https://github.com/hadolint/hadolint/releases/latest/download"; \
else \
release_url="https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION#v}"; \
fi; \
if curl -fsSL --remove-on-error "${release_url}/checksums.sha256" -o checksums.sha256; then \
binary=$(awk -v want="hadolint-linux-${hadolint_arch}" '{name=$2; sub(/^\*/,"",name); if (tolower(name)==tolower(want)) {print name; exit}}' checksums.sha256); \
[ -n "$binary" ] || { echo "no checksums.sha256 entry for hadolint-linux-${hadolint_arch}" >&2; exit 1; }; \
curl -fsSL --remove-on-error "${release_url}/${binary}" -o "${binary}"; \
grep -F "${binary}" checksums.sha256 | sha256sum -c -; \
else \
curl -fsSL --remove-on-error "${release_url}/hadolint-linux-${hadolint_arch}.sha256" -o guess.sha256; \
binary=$(awk '{name=$2; sub(/^\*/,"",name); print name}' guess.sha256); \
mv guess.sha256 "${binary}.sha256"; \
curl -fsSL --remove-on-error "${release_url}/${binary}" -o "${binary}"; \
sha256sum -c "${binary}.sha256"; \
fi; \
install -m 0755 "${binary}" /usr/local/bin/hadolint; \
cd /; rm -rf "$dest"

# wizcli (downloads.wiz.io) — same as setup-wizcli/action.yml; no version pin or checksum available upstream
RUN set -eux; \
dest="/tmp/wizcli"; mkdir -p "$dest"; cd "$dest"; \
curl -fsSL "https://downloads.wiz.io/v1/wizcli/latest/wizcli-linux-${TARGETARCH}" -o wizcli; \
install -m 0755 wizcli /usr/local/bin/wizcli; \
cd /; rm -rf "$dest"

# trivy (github.com/aquasecurity/trivy)
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) trivy_arch="64bit" ;; \
arm64) trivy_arch="ARM64" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
dest="/tmp/trivy"; mkdir -p "$dest"; cd "$dest"; \
asset="trivy_${TRIVY_VERSION#v}_Linux-${trivy_arch}.tar.gz"; \
base_url="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION#v}"; \
curl -fsSL "${base_url}/${asset}" -o "${asset}"; \
curl -fsSL "${base_url}/trivy_${TRIVY_VERSION#v}_checksums.txt" -o checksums.txt; \
sha256sum --ignore-missing -c checksums.txt; \
tar -xzf "${asset}" trivy; \
install -m 0755 trivy /usr/local/bin/trivy; \
cd /; rm -rf "$dest"

# oras (github.com/oras-project/oras) — `bakery ci merge`'s imagetools
# plugin shells out to it directly.
RUN set -eux; \
dest="/tmp/oras"; mkdir -p "$dest"; cd "$dest"; \
oras_num="${ORAS_VERSION#v}"; \
asset="oras_${oras_num}_linux_${TARGETARCH}.tar.gz"; \
base_url="https://github.com/oras-project/oras/releases/download/v${oras_num}"; \
curl -fsSL "${base_url}/${asset}" -o "${asset}"; \
curl -fsSL "${base_url}/oras_${oras_num}_checksums.txt" -o checksums.txt; \
sha256sum --ignore-missing -c checksums.txt; \
tar -xzf "${asset}" oras; \
install -m 0755 oras /usr/local/bin/oras; \
cd /; rm -rf "$dest"

COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["bash"]
14 changes: 14 additions & 0 deletions oven/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail

: "${BAKERY_REPO_PATH:?BAKERY_REPO_PATH must be set to the images-shared checkout path}"

uv sync --project "${BAKERY_REPO_PATH}/posit-bakery"

cat >/opt/oven/bin/bakery <<EOF
#!/usr/bin/env bash
exec uv run --project "${BAKERY_REPO_PATH}/posit-bakery" bakery "\$@"
EOF
chmod +x /opt/oven/bin/bakery

exec "${@:-bash}"
Loading