diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..437cc1807 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,134 @@ +name: Docker + +on: + push: + 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 }}-${{ github.event_name }} + 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@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: build + uses: docker/build-push-action@v7 + 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@v7 + 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@v8 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v4 + + - id: meta + uses: docker/metadata-action@v6 + 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}} + type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }} + + - uses: docker/login-action@v4 + 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/.github/workflows/release.yml b/.github/workflows/release.yml index 1b5e25eeb..2ee8580ae 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,16 @@ 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 + # docker.yml logs into GHCR and pushes the multi-arch image manifest. + permissions: + contents: read + packages: write + uses: ./.github/workflows/docker.yml + with: + version: ${{ needs.github-release.outputs.version }} diff --git a/Dockerfile b/Dockerfile index 068160549..31fcbe16c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,15 @@ # 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. +# +# 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 @@ -33,7 +41,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 +59,35 @@ 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. -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)" +# 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. +# 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 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. 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"]