diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ca9421..8d70741 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,8 @@ name: Build # - stable tags publish semver tags and update `latest` # (v1.2.3 -> latest, 1.2.3, 1.2, 1, sha-*); prereleases do not update latest # - pull requests run the checks and BUILD the image (nothing is pushed) +# Both architectures are built and smoke-tested on native runners before +# their digests are combined into the published multi-platform tags. # Images go to GitHub's own registry (GHCR) with the built-in GITHUB_TOKEN. on: @@ -48,9 +50,18 @@ jobs: for t in tests/*.sh; do bash "$t"; done docker: - name: Build and push image + name: Build and test (${{ matrix.arch }}) needs: quality - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 permissions: contents: read packages: write @@ -59,36 +70,110 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + - name: Build smoke-test image uses: docker/build-push-action@v7 with: context: . - platforms: linux/amd64 + platforms: linux/${{ matrix.arch }} load: true tags: claude-code-remote:smoke - cache-from: type=gha - cache-to: type=gha,mode=max + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=image-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=image-${{ matrix.arch }} # The entrypoint needs a signed-in account, so check the toolbox the # image promises instead of starting the supervisor. - name: Smoke-test image run: | - docker run --rm --entrypoint bash claude-code-remote:smoke -ec ' + docker run --rm --platform linux/${{ matrix.arch }} \ + --env EXPECTED_ARCH=${{ matrix.arch }} \ + --entrypoint bash claude-code-remote:smoke -ec ' + test "$(dpkg --print-architecture)" = "$EXPECTED_ARCH" claude --version docker --version docker compose version + docker buildx version git --version + node --version + npm --version + python3 --version for f in entrypoint.sh healthcheck.sh claude-login; do test -x "/usr/local/bin/$f" done ' + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Reuse the tested build's cached layers and labels. Push by digest so + # neither architecture can replace a shared tag with a single-platform image. + - name: Push tested image by digest + id: push + if: github.event_name != 'pull_request' + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/${{ matrix.arch }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + if: github.event_name != 'pull_request' + env: + IMAGE_DIGEST: ${{ steps.push.outputs.digest }} + run: | + mkdir -p "$RUNNER_TEMP/digests" + printf '%s\n' "$IMAGE_DIGEST" > "$RUNNER_TEMP/digests/${{ matrix.arch }}" + + - name: Upload digest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish multi-platform image + needs: docker + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Download digests + uses: actions/download-artifact@v8 + with: + path: ${{ runner.temp }}/digests + pattern: digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Docker meta id: meta uses: docker/metadata-action@v6 @@ -102,20 +187,57 @@ jobs: type=sha - name: Login to GHCR - if: github.event_name != 'pull_request' uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v7 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Publish manifest + working-directory: ${{ runner.temp }}/digests + env: + IMAGE: ghcr.io/${{ github.repository }} + IMAGE_TAGS: ${{ steps.meta.outputs.tags }} + run: | + sources=() + for arch in amd64 arm64; do + digest=$(cat "$arch") + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + sources+=("$IMAGE@$digest") + done + tags=() + while IFS= read -r tag; do + tags+=(--tag "$tag") + done <<< "$IMAGE_TAGS" + docker buildx imagetools create "${tags[@]}" "${sources[@]}" + + - name: Inspect published image + env: + IMAGE: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }} + run: | + docker buildx imagetools inspect "$IMAGE" + docker buildx imagetools inspect "$IMAGE" --raw | jq -e ' + [.manifests[].platform | select(.os == "linux") | .architecture] + | sort == ["amd64", "arm64"] + ' + + - name: Verify public image access + env: + IMAGE_PATH: ${{ github.repository }} + IMAGE_TAG: ${{ steps.meta.outputs.version }} + run: | + # Request an anonymous pull token without Docker credentials or GITHUB_TOKEN. + if ! token=$(curl --disable --fail --silent --show-error --get \ + --data-urlencode 'service=ghcr.io' \ + --data-urlencode "scope=repository:$IMAGE_PATH:pull" \ + https://ghcr.io/token | jq -er '.token // .access_token'); then + echo "::error::Cannot pull ghcr.io/$IMAGE_PATH anonymously. Set the package visibility to Public in GitHub Package settings." + exit 1 + fi + curl --disable --fail --silent --show-error \ + --header "Authorization: Bearer $token" \ + --header 'Accept: application/vnd.oci.image.index.v1+json' \ + "https://ghcr.io/v2/$IMAGE_PATH/manifests/$IMAGE_TAG" | jq -e ' + [.manifests[].platform | select(.os == "linux") | .architecture] + | sort == ["amd64", "arm64"] + ' diff --git a/README.md b/README.md old mode 100755 new mode 100644 index f901b3b..6de2869 --- a/README.md +++ b/README.md @@ -165,9 +165,12 @@ docker compose pull && docker compose up -d ## The image -CI publishes the image to the GitHub Container Registry for linux/amd64 and -linux/arm64, once the script checks, the tests and a smoke test of the built -image pass: +CI publishes the image to the GitHub Container Registry for **linux/amd64** +(x86-64) and **linux/arm64** (AArch64, including Raspberry Pi with a 64-bit OS +and Apple Silicon running Linux containers). Each architecture is built and +smoke-tested on a native runner, including the Claude CLI, Docker CLI and +plugins, Node.js, Python and Git. Shared tags are published only after the +script checks, regression tests and both architecture smoke tests pass: | Tag | Points at | |---|---| @@ -176,8 +179,22 @@ image pass: | `1.2.3`, `1.2`, `1` | the release tag `v1.2.3` | `compose.yaml` pulls `ghcr.io/markfrancisonly/claude-code-remote`, tagged by -`DOCKER_CLAUDE_IMAGE_TAG` in `.env` (default `latest`). To run your own -changes instead, build it locally: `docker compose up -d --build`. +`DOCKER_CLAUDE_IMAGE_TAG` in `.env` (default `latest`). Docker automatically +selects the matching architecture, so ARM64 hosts use the same Compose file +and tags without a `platform` override. A 64-bit OS is required; 32-bit ARM +(`arm/v7`) is not supported. + +The GHCR package must be **Public** so these pulls work without a GitHub +login. After the first publication, the maintainer must select **Public** +under **Package settings → Change visibility**; a public repository does +not make its container package public automatically. See +[GitHub's package visibility instructions](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility#configuring-visibility-of-packages-for-your-personal-account). +CI checks anonymous access to the published manifest and fails if the image +cannot be pulled publicly. After changing visibility, rerun the failed job. + +To run your own changes instead, build natively on either supported +architecture with `docker compose up -d --build`. The Ubuntu packages and +Claude installer select binaries for the build's target architecture. Host-only settings (extra mounts, labels, or `pull_policy: never` to always build this checkout) belong in a `compose.override.yaml` next to