From a81fca4bd1cade9191be6c018b648a6c608c084a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 02:47:09 +0000 Subject: [PATCH 1/7] ci(docker): portable multi-arch image, unprivileged runtime, ghcr publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop `-C target-cpu=native`: a published image must run on any host of its arch, so keep the portable musl baseline. native risked SIGILL on older/different CPUs and is meaningless under emulated cross-arch builds. - Cook the dependency layer with `--example demo` so the example's dev-deps (env_logger, …) land in the cached layer instead of recompiling after COPY. - Run as unprivileged uid 65532 with writable /data (named-volume ownership) and /tmp for SQLite temp files, staged via COPY --chown. - Add docker.yml: build linux/amd64 + linux/arm64 on native runners, push by digest, merge into one manifest, publish to GHCR. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0161LFHLPTdCNyianZFH8dor --- .github/workflows/docker.yml | 122 +++++++++++++++++++++++++++++++++++ Dockerfile | 30 +++++++-- 2 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..831ad6cef --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,122 @@ +name: Docker + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +# Native per-arch runners instead of QEMU: build-std + fat LTO is far too slow +# under emulation. Each arch builds in parallel, pushed by digest, then merged +# into one multi-arch manifest. +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + steps: + - name: Prepare platform pair + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + + - uses: actions/checkout@v6 + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + # Per-arch cache scope so the two matrix legs never clobber each other. + cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: [build] + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=semver,pattern={{version}} + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + env: + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} + + - name: Inspect + run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/Dockerfile b/Dockerfile index 068160549..fc8663e2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,9 @@ # Build: docker build -t whatsapp-rust . # Run: docker run -v whatsapp-data:/data whatsapp-rust # -# The /data volume persists the SQLite database across restarts. +# The /data volume persists the SQLite database across restarts. The image runs +# unprivileged (uid 65532); use a named volume (as below) so it inherits that +# ownership and stays writable — a host bind mount would need matching ownership. # Pass --phone for pair code auth: # docker run -v whatsapp-data:/data whatsapp-rust --phone 15551234567 @@ -33,7 +35,11 @@ FROM chef AS builder # (measured: -666 KiB, -5.6% .text). Nightly-only, which this image already # pins via rust-toolchain.toml; with fat LTO the historical inlining downside # does not apply since LTO sees all bitcode anyway. -ENV RUSTFLAGS="-C target-cpu=native -Zshare-generics=y" +# No -C target-cpu: a published image must run on any host of its arch, so we +# keep the portable musl baseline instead of pinning the build machine's CPU +# (target-cpu=native would risk SIGILL on older/different hosts and is +# meaningless under emulated cross-arch builds). +ENV RUSTFLAGS="-Zshare-generics=y" # build-std recompiles std with the release profile so it participates in fat # LTO and dead-code elimination instead of linking the prebuilt rustup std @@ -47,19 +53,29 @@ ENV CARGO_UNSTABLE_BUILD_STD="std,panic_abort" # chef stage's copy at /; the nightly-only RUSTFLAGS above depends on it. COPY rust-toolchain.toml . COPY --from=planner /app/recipe.json recipe.json -# build-std demands an explicit --target; use the image's own host triple so -# multi-arch builds (e.g. buildx linux/arm64) keep producing native binaries -# exactly like the implicit-target build did. +# build-std demands an explicit --target; derive the image's own host triple so +# buildx per-arch builds (linux/amd64, linux/arm64) each target their own arch. RUN rustc -vV | sed -n 's/^host: //p' > /rust-target && test -s /rust-target -RUN cargo chef cook --release --recipe-path recipe.json --target "$(cat /rust-target)" +# Cook with the same --example as the final build so the example's dev-deps +# (env_logger, …) are cached here instead of recompiling after the source COPY. +RUN cargo chef cook --release --recipe-path recipe.json --target "$(cat /rust-target)" --example demo COPY . . # The client lives in examples/demo.rs (the package no longer ships a bin); the # example artifact lands under release/examples/. Default features cover it. RUN cargo build --release --example demo --target "$(cat /rust-target)" \ && cp "target/$(cat /rust-target)/release/examples/demo" /app/whatsapp-rust-bin -# --- Runtime: static binary on empty image --- +# Empty dirs to stage into the scratch image; the COPY --chown below grants them +# to the unprivileged uid so /data (DB; a fresh named volume inherits the +# ownership) and /tmp (SQLite temp files, absent on scratch) stay writable. +RUN mkdir -p /newroot/data /newroot/tmp + +# --- Runtime: static binary on empty image, unprivileged --- FROM scratch +COPY --from=builder --chown=65532:65532 /newroot/tmp /tmp +COPY --from=builder --chown=65532:65532 /newroot/data /data COPY --from=builder /app/whatsapp-rust-bin /whatsapp-rust +ENV TMPDIR=/tmp WORKDIR /data +USER 65532:65532 ENTRYPOINT ["/whatsapp-rust"] From a27bd695cdb41f9b359d1586f4a6f9439b63a579 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 03:08:12 +0000 Subject: [PATCH 2/7] ci(docker): split rustc target extraction off the pipe (Hadolint DL4006) Piping rustc -vV into sed let sed's exit status drive the layer, masking a rustc failure. Write the version to a temp file first so the failure path is explicit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0161LFHLPTdCNyianZFH8dor --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fc8663e2d..7190653d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,11 @@ COPY rust-toolchain.toml . COPY --from=planner /app/recipe.json recipe.json # build-std demands an explicit --target; derive the image's own host triple so # buildx per-arch builds (linux/amd64, linux/arm64) each target their own arch. -RUN rustc -vV | sed -n 's/^host: //p' > /rust-target && test -s /rust-target +# No pipe, so a rustc failure isn't masked by sed's exit status (Hadolint DL4006). +RUN rustc -vV > /rustc-version \ + && sed -n 's/^host: //p' /rustc-version > /rust-target \ + && test -s /rust-target \ + && rm /rustc-version # Cook with the same --example as the final build so the example's dev-deps # (env_logger, …) are cached here instead of recompiling after the source COPY. RUN cargo chef cook --release --recipe-path recipe.json --target "$(cat /rust-target)" --example demo From f1e3db454690cd3b7f45224722fe0e61ffb384fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 03:12:41 +0000 Subject: [PATCH 3/7] fix(docker): use cargo-chef's --examples (no singular --example exists) cargo-chef 0.1.77's `cook` CLI defines `--examples` (plural bool) but not `--example `, so `--example demo` aborted the dependency layer with a clap unexpected-argument error. Under default features `--examples` builds only demo (the other examples gate on extra features), and dev-deps are per-package, so the demo's dev-deps still land in the cached layer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0161LFHLPTdCNyianZFH8dor --- Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7190653d9..7657e1434 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,9 +60,11 @@ RUN rustc -vV > /rustc-version \ && sed -n 's/^host: //p' /rustc-version > /rust-target \ && test -s /rust-target \ && rm /rustc-version -# Cook with the same --example as the final build so the example's dev-deps -# (env_logger, …) are cached here instead of recompiling after the source COPY. -RUN cargo chef cook --release --recipe-path recipe.json --target "$(cat /rust-target)" --example demo +# Cook examples so the demo's dev-deps (env_logger, …) are cached, not rebuilt +# after the source COPY. cargo-chef exposes only the plural --examples (no +# --example ); under default features that builds just demo, since the +# other examples gate on extra features. +RUN cargo chef cook --release --recipe-path recipe.json --target "$(cat /rust-target)" --examples COPY . . # The client lives in examples/demo.rs (the package no longer ships a bin); the # example artifact lands under release/examples/. Default features cover it. From 33dfdfac57cc88cddf5ee0e5cca658ff258d39cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 03:12:41 +0000 Subject: [PATCH 4/7] ci(docker): bump workflow actions to current major versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-buildx@v4, login@v4, metadata@v6, build-push@v7, upload-artifact@v7, download-artifact@v8 — current releases (verified), for Node 24 runtime and latest fixes. Only stable, still-supported inputs are used. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0161LFHLPTdCNyianZFH8dor --- .github/workflows/docker.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 831ad6cef..bd28d4923 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -40,20 +40,20 @@ jobs: - uses: actions/checkout@v6 - id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ matrix.platform }} @@ -70,7 +70,7 @@ jobs: touch "${{ runner.temp }}/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: digests-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* @@ -85,16 +85,16 @@ jobs: packages: write steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: ${{ runner.temp }}/digests pattern: digests-* merge-multiple: true - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 - id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -104,7 +104,7 @@ jobs: type=sha type=raw,value=latest,enable={{is_default_branch}} - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From a362145087b1566c574fcab96e0f6d124b0477f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 03:24:03 +0000 Subject: [PATCH 5/7] ci(release): publish the Docker image from the release workflow A v* tag pushed by release.yml with GITHUB_TOKEN can't trigger docker.yml's tag event (GitHub suppresses workflow runs from token-pushed refs), so semver image tags were never produced by automated releases. Make docker.yml callable via workflow_call with a version input and invoke it from release.yml after the GitHub release, tagging the image :. Push-to-main keeps publishing :latest/:sha on its own. Concurrency now keys on event_name so a main push can't cancel a release's build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0161LFHLPTdCNyianZFH8dor --- .github/workflows/docker.yml | 14 +++++++++++++- .github/workflows/release.yml | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bd28d4923..437cc1807 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -5,12 +5,23 @@ on: branches: [main] tags: ['v*'] workflow_dispatch: + # Called by release.yml: a v* tag it pushes with GITHUB_TOKEN can't trigger + # the tag event above, so the release invokes this directly and passes the + # version to tag the image with. + workflow_call: + inputs: + version: + description: 'Image version tag, e.g. 0.6.0 (set by the release workflow)' + required: false + type: string + default: '' # Native per-arch runners instead of QEMU: build-std + fat LTO is far too slow # under emulation. Each arch builds in parallel, pushed by digest, then merged # into one multi-arch manifest. +# event_name keys the group so a main push can't cancel a release's build. concurrency: - group: docker-${{ github.ref }} + group: docker-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true env: @@ -103,6 +114,7 @@ jobs: type=semver,pattern={{version}} type=sha type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }} - uses: docker/login-action@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b5e25eeb..8af44b597 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,8 @@ jobs: name: Create GitHub Release needs: publish runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v6 with: @@ -97,3 +99,15 @@ jobs: --generate-notes \ --latest fi + + # The tag pushed above with GITHUB_TOKEN can't trigger docker.yml's tag event, + # so invoke it directly and pass the version for the :X.Y.Z image tag. + publish-docker: + name: Publish Docker image + needs: github-release + permissions: + contents: read + packages: write + uses: ./.github/workflows/docker.yml + with: + version: ${{ needs.github-release.outputs.version }} From 53d311e31db9eee1c4b6ccd7e52d4363183012e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 03:30:00 +0000 Subject: [PATCH 6/7] ci(release): document the packages:write grant on the docker job (zizmor) zizmor's undocumented-permissions flags permission grants without a rationale; note that the called docker.yml logs into GHCR and pushes the manifest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0161LFHLPTdCNyianZFH8dor --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8af44b597..2ee8580ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,7 @@ jobs: publish-docker: name: Publish Docker image needs: github-release + # docker.yml logs into GHCR and pushes the multi-arch image manifest. permissions: contents: read packages: write From c8cbee111d1e5716d7c0ab04b04d63864df214e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 03:36:38 +0000 Subject: [PATCH 7/7] docs(docker): note the one-time chown for pre-existing root-owned volumes Dropping to uid 65532 can't write a whatsapp-data volume that an older root-running image populated; Docker won't re-chown an existing named volume. Auto-migration isn't possible on scratch (no shell/su-exec), so document the one-time chown via a throwaway image. Fresh volumes inherit 65532 and are unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0161LFHLPTdCNyianZFH8dor --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index 7657e1434..31fcbe16c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,12 @@ # The /data volume persists the SQLite database across restarts. The image runs # unprivileged (uid 65532); use a named volume (as below) so it inherits that # ownership and stays writable — a host bind mount would need matching ownership. +# +# Upgrading from the old root-running image? An existing volume keeps its +# root-owned files, which uid 65532 can't write. Chown it once with any image +# that ships chown (scratch doesn't): +# docker run --rm -v whatsapp-data:/data alpine chown -R 65532:65532 /data +# # Pass --phone for pair code auth: # docker run -v whatsapp-data:/data whatsapp-rust --phone 15551234567