diff --git a/.github/actions/setup-dev-image/action.yml b/.github/actions/setup-dev-image/action.yml index f94729e..e4c126e 100644 --- a/.github/actions/setup-dev-image/action.yml +++ b/.github/actions/setup-dev-image/action.yml @@ -46,6 +46,22 @@ runs: echo "ACTIONS_RUNTIME_TOKEN=${ACTIONS_RUNTIME_TOKEN}" } >> "$GITHUB_ENV" + # Run the compose containers as root on CI. The image defaults to a + # non-root user (UID 1000) so local bind-mount writes are host-owned, but + # the runner's checkout is owned by a *different* UID — a non-root + # container can't create files (wasm pkg/, mdbook book/, dist/) inside + # runner-owned tree directories. Ownership is throwaway on the ephemeral + # runner, so root is the simplest correct choice and matches the + # historical (proven-green) CI behaviour. Read by `user:` in + # docker-compose.yml via the AFM_UID/AFM_GID defaults. + - name: Run compose containers as root on CI + shell: bash + run: | + { + echo "AFM_UID=0" + echo "AFM_GID=0" + } >> "$GITHUB_ENV" + # docker/login-action authenticates the runner against GHCR using # the standard GITHUB_TOKEN. Skipped on fork PRs where the token # lacks `packages: read` — those runs go through the build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01eb46e..eba2377 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,6 +210,13 @@ jobs: book: runs-on: ubuntu-latest + # This job invokes `docker compose run book` directly (not via the + # setup-dev-image action), so set the root override here too — the book + # image defaults to a non-root user that can't write mdbook output into + # the runner-owned checkout. See docker-compose.yml `user:`. + env: + AFM_UID: "0" + AFM_GID: "0" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 diff --git a/Dockerfile b/Dockerfile index 2730bd4..cd01c9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -170,9 +170,29 @@ ENV CARGO_HOME=/cargo/home \ # on purpose (see docker-compose.yml): nesting them under /workspace # made the daemon create root-owned ./target / ./.cargo / ./.sccache # on the host, littering the working tree and breaking host-side cargo. -RUN mkdir -p /cargo/target /cargo/home/registry /cargo/home/git /cargo/sccache +RUN mkdir -p /cargo/target /cargo/home/registry /cargo/home/git /cargo/sccache \ + /workspace/playground/node_modules + +# Run as a non-root user so files written into the /workspace bind mount +# (generated artefacts, wasm pkg/, mdbook output, node_modules) are owned +# by the host developer, not root. UID/GID default to the conventional +# first-user 1000; override with `--build-arg UID=$(id -u) --build-arg +# GID=$(id -g)` on hosts that differ. Debian bookworm's base leaves 1000 +# free, so the create is unconditional (and fails loudly if it ever isn't). +# The cache dirs at /cargo/* and the playground node_modules mountpoint are +# chowned so a *fresh* named volume initialises dev-owned. CI flips the +# runtime UID back to root via `user:` in docker-compose.yml (AFM_UID=0): +# the ephemeral runner's checkout is owned by a different UID and ownership +# is throwaway there, so root sidesteps cross-UID write failures. +ARG UID=1000 +ARG GID=1000 +RUN groupadd --gid "${GID}" dev \ + && useradd --uid "${UID}" --gid "${GID}" --create-home --shell /bin/bash dev \ + && chown -R "${UID}:${GID}" /cargo /workspace +ENV HOME=/home/dev WORKDIR /workspace +USER dev # Default shell friendly for interactive dev sessions CMD ["bash"] @@ -189,6 +209,11 @@ CMD ["bash"] ######################################################################## FROM dev AS fuzz +# The dev stage ends as USER dev; the nightly toolchain + cargo-fuzz/udeps +# installs below write to /usr/local (root-owned), so switch back to root +# for the install layers and drop to dev again at the end. +USER root + # `rustup toolchain install` tries to self-update by looking for the # rustup binary at $CARGO_HOME/bin/rustup. The inherited # `CARGO_HOME=/cargo/home` (set in the dev stage for runtime @@ -204,6 +229,8 @@ RUN cargo binstall --no-confirm --locked --root /usr/local \ cargo-fuzz \ cargo-udeps +USER dev + ######################################################################## # Stage: ci — fuzz superset; the published GHCR image (used by CI matrix # jobs) carries every tool every recipe might invoke. @@ -218,7 +245,17 @@ FROM node-base AS book COPY --from=cargo-tools /usr/local/bin/mdbook /usr/local/bin/mdbook COPY --from=cargo-tools /usr/local/bin/mdbook-linkcheck /usr/local/bin/mdbook-linkcheck +# Match the dev stage's non-root user so mdbook output written into the +# /workspace bind mount is host-owned, not root. `book` is FROM node-base +# (not dev), so it creates its own identical `dev` user. +ARG UID=1000 +ARG GID=1000 +RUN groupadd --gid "${GID}" dev \ + && useradd --uid "${UID}" --gid "${GID}" --create-home --shell /bin/bash dev +ENV HOME=/home/dev + WORKDIR /workspace/crates/afm-book +USER dev EXPOSE 3000 CMD ["mdbook", "serve", "--hostname", "0.0.0.0", "--port", "3000"] diff --git a/docker-compose.yml b/docker-compose.yml index 4cef1fc..3f4f5b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,11 +43,12 @@ x-common-env: &common-env CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-fuse-ld=mold" CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: "clang" TERM: ${TERM:-xterm-256color} - # /workspace is bind-mounted from the host as a non-root UID, but the - # container runs as root. Git otherwise refuses operations with - # "dubious ownership"; whitelist via the GIT_CONFIG_* triplet rather - # than baking a `git config` invocation into the image (keeps the - # whitelist local to the compose surface and trivially removable). + # /workspace is bind-mounted from the host. Locally the container runs as + # the matching host UID (1000) so git is happy; in CI it runs as root + # (AFM_UID=0) over a checkout owned by the runner's UID, and git then + # refuses operations with "dubious ownership". Whitelist via the + # GIT_CONFIG_* triplet rather than baking a `git config` invocation into + # the image (keeps the whitelist local to the compose surface). GIT_CONFIG_COUNT: "1" GIT_CONFIG_KEY_0: "safe.directory" GIT_CONFIG_VALUE_0: "/workspace" @@ -70,6 +71,12 @@ services: target: dev image: afm-dev:local working_dir: /workspace + # Runtime UID/GID. Unset locally → 1000 (the image's baked `dev` user, + # matched to the conventional host UID) so bind-mount writes are + # host-owned. CI sets AFM_UID=0/AFM_GID=0 (setup-dev-image action) → root, + # since the runner's checkout is owned by a different UID and ownership + # is throwaway on the ephemeral runner. + user: "${AFM_UID:-1000}:${AFM_GID:-1000}" environment: *common-env volumes: - .:/workspace:cached @@ -91,6 +98,7 @@ services: target: fuzz image: afm-fuzz:local working_dir: /workspace + user: "${AFM_UID:-1000}:${AFM_GID:-1000}" # see `dev` service environment: *common-env volumes: - .:/workspace:cached @@ -109,6 +117,7 @@ services: target: ci image: afm-ci:local working_dir: /workspace + user: "${AFM_UID:-1000}:${AFM_GID:-1000}" # see `dev` service environment: <<: *common-env CI: "true" @@ -131,6 +140,7 @@ services: target: book image: afm-book:local working_dir: /workspace/crates/afm-book + user: "${AFM_UID:-1000}:${AFM_GID:-1000}" # see `dev` service volumes: - .:/workspace:cached ports: @@ -164,6 +174,7 @@ services: target: dev image: afm-dev:local working_dir: /workspace/playground + user: "${AFM_UID:-1000}:${AFM_GID:-1000}" # see `dev` service environment: *common-env volumes: - .:/workspace:cached