diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index f84abee9..98f5c328 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -10,15 +10,27 @@ on: description: Stable tag to publish manually, for example v7.55.0 required: true type: string + promote_to_latest: + description: > + Promote mutable :latest / :full tags (and major/minor aliases). + Use only after a successful rc.1 soak period. Defaults to false so + the first publish of a tag only pushes the immutable version tags. + required: false + type: boolean + default: false concurrency: group: docker-release-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag || github.ref }} cancel-in-progress: false jobs: - publish: + # --------------------------------------------------------------------------- + # Job 1: Legacy ccs-dashboard image (2-release sunset window) + # --------------------------------------------------------------------------- + publish-dashboard: + name: Publish legacy ccs-dashboard image if: ${{ github.event_name != 'release' || !github.event.release.prerelease }} - runs-on: [self-hosted, linux, x64] + runs-on: [self-hosted, linux, x64, cliproxy] permissions: contents: read @@ -36,7 +48,6 @@ jobs: else TARGET_TAG="${MANUAL_TAG}" fi - echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT" - name: Validate stable semver tag @@ -46,7 +57,6 @@ jobs: echo "publish=true" >> "$GITHUB_OUTPUT" exit 0 fi - echo "publish=false" >> "$GITHUB_OUTPUT" echo "Skipping non-stable semver tag ${{ steps.target.outputs.tag }}" @@ -67,8 +77,6 @@ jobs: - name: Derive image metadata if: steps.tag.outputs.publish == 'true' id: meta - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ steps.target.outputs.tag }}" VERSION="${VERSION#v}" @@ -105,7 +113,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push release image + - name: Build and push legacy dashboard image if: steps.tag.outputs.publish == 'true' uses: docker/build-push-action@v6 with: @@ -116,13 +124,284 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: | org.opencontainers.image.title=ccs-dashboard - org.opencontainers.image.description=CCS Dashboard container image + org.opencontainers.image.description=CCS Dashboard container image (deprecated - migrate to ghcr.io/kaitranntt/ccs:latest) org.opencontainers.image.url=https://github.com/${{ github.repository }} org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.version=${{ steps.meta.outputs.version }} org.opencontainers.image.revision=${{ steps.meta.outputs.revision }} annotations: | index:org.opencontainers.image.source=https://github.com/${{ github.repository }} - index:org.opencontainers.image.description=CCS Dashboard container image - cache-from: type=gha - cache-to: type=gha,mode=max + index:org.opencontainers.image.description=CCS Dashboard container image (deprecated - migrate to ghcr.io/kaitranntt/ccs:latest) + cache-from: type=gha,scope=dashboard + cache-to: type=gha,mode=max,scope=dashboard + + # --------------------------------------------------------------------------- + # Job 2: Integrated image matrix — minimal (ccs:latest) and full (ccs:full) + # --------------------------------------------------------------------------- + publish-integrated: + name: Publish integrated image (${{ matrix.flavor }}) + if: ${{ github.event_name != 'release' || !github.event.release.prerelease }} + runs-on: [self-hosted, linux, x64, cliproxy] + + permissions: + contents: read + packages: write + + strategy: + fail-fast: false + matrix: + flavor: [minimal, full] + + steps: + - name: Resolve target tag + id: target + env: + MANUAL_TAG: ${{ inputs.tag }} + RELEASE_EVENT_TAG: ${{ github.event.release.tag_name }} + run: | + if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + TARGET_TAG="${RELEASE_EVENT_TAG}" + else + TARGET_TAG="${MANUAL_TAG}" + fi + echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT" + + - name: Validate stable semver tag + id: tag + run: | + if [[ "${{ steps.target.outputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "Skipping non-stable semver tag ${{ steps.target.outputs.tag }}" + + - name: Checkout release tag + if: steps.tag.outputs.publish == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ steps.target.outputs.tag }} + + - name: Set up QEMU + if: steps.tag.outputs.publish == 'true' + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + if: steps.tag.outputs.publish == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Derive image metadata + if: steps.tag.outputs.publish == 'true' + id: meta + env: + FLAVOR: ${{ matrix.flavor }} + PROMOTE_TO_LATEST: ${{ inputs.promote_to_latest || 'false' }} + run: | + VERSION="${{ steps.target.outputs.tag }}" + VERSION="${VERSION#v}" + MINOR="${VERSION%.*}" + MAJOR="${VERSION%%.*}" + OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') + IMAGE="ghcr.io/${OWNER_LOWER}/ccs" + REVISION=$(git rev-parse HEAD) + + # Determine tag prefix for the full flavor + if [[ "${FLAVOR}" == "full" ]]; then + PREFIX="full-" + MUTABLE_TAG="${IMAGE}:full" + else + PREFIX="" + MUTABLE_TAG="${IMAGE}:latest" + fi + + # Immutable version-pinned tag is always published + TAGS="${IMAGE}:${PREFIX}${VERSION}" + + # Mutable tags (:latest / :full / major / minor aliases) are published only when: + # - triggered by a GitHub release event (not a prerelease), OR + # - workflow_dispatch with promote_to_latest=true + if [[ "${GITHUB_EVENT_NAME}" == "release" || "${PROMOTE_TO_LATEST}" == "true" ]]; then + TAGS="${TAGS} + ${IMAGE}:${PREFIX}${MINOR} + ${IMAGE}:${PREFIX}${MAJOR} + ${MUTABLE_TAG}" + fi + + { + echo "version=${VERSION}" + echo "minor=${MINOR}" + echo "major=${MAJOR}" + echo "image=${IMAGE}" + echo "revision=${REVISION}" + echo "prefix=${PREFIX}" + echo "tags<> "$GITHUB_OUTPUT" + + - name: Log in to GitHub Container Registry + if: steps.tag.outputs.publish == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push integrated image (${{ matrix.flavor }}) + if: steps.tag.outputs.publish == 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile.integrated + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + FLAVOR=${{ matrix.flavor }} + CCS_NPM_VERSION=${{ steps.meta.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + labels: | + org.opencontainers.image.title=ccs + org.opencontainers.image.description=CCS integrated image (${{ matrix.flavor }} flavor) + org.opencontainers.image.url=https://github.com/${{ github.repository }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ steps.meta.outputs.version }} + org.opencontainers.image.revision=${{ steps.meta.outputs.revision }} + annotations: | + index:org.opencontainers.image.source=https://github.com/${{ github.repository }} + index:org.opencontainers.image.description=CCS integrated image (${{ matrix.flavor }} flavor) + cache-from: type=gha,scope=integrated-${{ matrix.flavor }} + cache-to: type=gha,mode=max,scope=integrated-${{ matrix.flavor }} + + # --------------------------------------------------------------------------- + # Job 3: Post-publish smoke test — pull each variant and verify it boots + # --------------------------------------------------------------------------- + smoke-test: + name: Smoke test ${{ matrix.image }} + needs: [publish-integrated] + if: ${{ github.event_name != 'release' || !github.event.release.prerelease }} + runs-on: [self-hosted, linux, x64, cliproxy] + + strategy: + fail-fast: false + matrix: + include: + - flavor: minimal + image_suffix: "" + max_bytes: "367001600" + - flavor: full + image_suffix: "full-" + max_bytes: "629145600" + + steps: + - name: Resolve target tag + id: target + env: + MANUAL_TAG: ${{ inputs.tag }} + RELEASE_EVENT_TAG: ${{ github.event.release.tag_name }} + run: | + if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + TARGET_TAG="${RELEASE_EVENT_TAG}" + else + TARGET_TAG="${MANUAL_TAG}" + fi + echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT" + + - name: Validate stable semver tag + id: tag + run: | + if [[ "${{ steps.target.outputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "publish=false" >> "$GITHUB_OUTPUT" + + - name: Checkout release tag (for test scripts) + if: steps.tag.outputs.publish == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ steps.target.outputs.tag }} + + - name: Derive image reference + if: steps.tag.outputs.publish == 'true' + id: image + env: + IMAGE_SUFFIX: ${{ matrix.image_suffix }} + run: | + VERSION="${{ steps.target.outputs.tag }}" + VERSION="${VERSION#v}" + OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') + IMAGE_REF="ghcr.io/${OWNER_LOWER}/ccs:${IMAGE_SUFFIX}${VERSION}" + echo "ref=${IMAGE_REF}" >> "$GITHUB_OUTPUT" + + - name: Log in to GitHub Container Registry + if: steps.tag.outputs.publish == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull image + if: steps.tag.outputs.publish == 'true' + run: docker pull "${{ steps.image.outputs.ref }}" + + - name: Assert image size budget + if: steps.tag.outputs.publish == 'true' + run: | + chmod +x tests/docker/image-size.sh + tests/docker/image-size.sh "${{ steps.image.outputs.ref }}" "${{ matrix.max_bytes }}" + + - name: Boot container and wait for healthcheck + if: steps.tag.outputs.publish == 'true' + id: boot + run: | + CONTAINER_NAME="ccs-smoke-${{ matrix.flavor }}-${{ github.run_id }}" + echo "container=${CONTAINER_NAME}" >> "$GITHUB_OUTPUT" + + docker run -d \ + --name "${CONTAINER_NAME}" \ + --rm \ + -p 13000:3000 \ + -p 18317:8317 \ + "${{ steps.image.outputs.ref }}" + + echo "[i] Waiting for container healthcheck (up to 60s)..." + for i in $(seq 1 12); do + STATUS=$(docker inspect "${CONTAINER_NAME}" --format='{{.State.Health.Status}}' 2>/dev/null || echo "missing") + if [[ "${STATUS}" == "healthy" ]]; then + echo "[OK] Container is healthy after $((i * 5))s" + break + fi + if [[ "${STATUS}" == "unhealthy" ]]; then + echo "[X] Container marked unhealthy" + docker logs "${CONTAINER_NAME}" --tail 50 >&2 + exit 1 + fi + echo " [${i}/12] status=${STATUS}, waiting 5s..." + sleep 5 + done + + - name: Probe dashboard port 3000 + if: steps.tag.outputs.publish == 'true' + run: | + HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 http://127.0.0.1:13000/ || echo "000") + if [[ "${HTTP_CODE}" == "000" ]]; then + echo "[X] Could not reach dashboard on :3000 (connection failed)" + exit 1 + fi + echo "[OK] Dashboard port 3000 responded with HTTP ${HTTP_CODE}" + + - name: Probe CLIProxy port 8317 + if: steps.tag.outputs.publish == 'true' + run: | + HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 http://127.0.0.1:18317/ || echo "000") + if [[ "${HTTP_CODE}" == "000" ]]; then + echo "[X] Could not reach CLIProxy on :8317 (connection failed)" + exit 1 + fi + echo "[OK] CLIProxy port 8317 responded with HTTP ${HTTP_CODE}" + + - name: Stop smoke test container + if: always() && steps.tag.outputs.publish == 'true' + run: | + docker stop "ccs-smoke-${{ matrix.flavor }}-${{ github.run_id }}" 2>/dev/null || true diff --git a/CHANGELOG.md b/CHANGELOG.md index b474ebfc..1f79b9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [Unreleased] + +### Deprecated + +* **docker:** `ghcr.io/kaitranntt/ccs-dashboard:latest` Docker image is deprecated — migrate to `ghcr.io/kaitranntt/ccs:latest` (minimal, CCS + CLIProxy) or `ghcr.io/kaitranntt/ccs:full` (with claude-code, gemini-cli, grok-cli, opencode). The legacy image continues publishing for 2 more releases and emits a startup warning. See [#1251](https://github.com/kaitranntt/ccs/issues/1251). + ## [7.79.1](https://github.com/kaitranntt/ccs/compare/v7.79.0...v7.79.1) (2026-05-14) ### Hotfixes diff --git a/README.md b/README.md index 20b04f81..3dcdc56c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Anthropic-compatible APIs without config thrash. +> **[Docker]** `ghcr.io/kaitranntt/ccs-dashboard:latest` is deprecated. Use `ghcr.io/kaitranntt/ccs:latest` instead. See [#1251](https://github.com/kaitranntt/ccs/issues/1251) and [docker/README.md](docker/README.md#choosing-an-image) for migration details. + ## Why CCS CCS gives you one stable command surface while letting you switch between: diff --git a/docker/Dockerfile.integrated b/docker/Dockerfile.integrated index ff337726..41db1cdc 100644 --- a/docker/Dockerfile.integrated +++ b/docker/Dockerfile.integrated @@ -1,17 +1,32 @@ FROM eceasy/cli-proxy-api:latest ARG CCS_NPM_VERSION=latest +# FLAVOR=minimal installs CCS only; FLAVOR=full adds claude-code, gemini-cli, grok-cli, opencode +ARG FLAVOR=minimal RUN apk add --no-cache \ + bash \ curl \ jq \ nodejs \ npm \ supervisor -RUN npm install -g @kaitranntt/ccs@${CCS_NPM_VERSION} \ +# Install CCS CLI (always present in both flavors) +RUN --mount=type=cache,target=/root/.npm \ + npm install -g @kaitranntt/ccs@${CCS_NPM_VERSION} \ && ln -sf /usr/local/lib/node_modules/@kaitranntt/ccs/dist/docker/docker-bootstrap.js /usr/local/bin/ccs-docker-bootstrap +# Install AI CLIs only in the full flavor (all 4 bundled as one layer) +RUN --mount=type=cache,target=/root/.npm \ + if [ "$FLAVOR" = "full" ]; then \ + npm install -g --ignore-scripts \ + @anthropic-ai/claude-code \ + @google/gemini-cli \ + @vibe-kit/grok-cli \ + && curl -fsSL https://opencode.ai/install | sh -s -- --no-modify-path 2>/dev/null || true; \ + fi + COPY supervisord.conf /etc/supervisord.conf COPY entrypoint-integrated.sh /entrypoint-integrated.sh diff --git a/docker/README.md b/docker/README.md index 7991b994..8297374b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -13,6 +13,16 @@ Persistent config, restart on reboot.
+## Choosing an image + +| Tag | Use | Approx. size | Status | +|---|---|---|---| +| `ghcr.io/kaitranntt/ccs:latest` | CCS + CLIProxy, no AI CLIs pre-installed | < 350 MB | **Recommended** | +| `ghcr.io/kaitranntt/ccs:full` | CCS + CLIProxy + claude-code + gemini-cli + grok-cli + opencode | < 600 MB | Supported | +| `ghcr.io/kaitranntt/ccs-dashboard:latest` | Legacy all-in-one image | > 600 MB | **Deprecated** — migrate to `ccs:latest`. Sunset after 2 releases. See [#1251](https://github.com/kaitranntt/ccs/issues/1251) | + +Both `ccs:latest` and `ccs:full` also publish pinned version tags (`ccs:..`, `ccs:.`, `ccs:`) for reproducible deployments. The `:full` variants carry the `full-` prefix: `ccs:full-`, `ccs:full-`, etc. + ## Preferred: `ccs docker` The CLI now ships a first-class Docker command suite for the integrated CCS + CLIProxy stack: @@ -147,24 +157,38 @@ Expected healthy output: ## Prebuilt Image Quick Start -This existing image still runs the CCS dashboard and its locally managed CLIProxy inside one -container. It does not provide the remote staging and in-container self-update flow exposed by -`ccs docker`. - -Pull the latest stable release image from GitHub Container Registry: +Pull the recommended minimal image (CCS + CLIProxy, no AI CLIs): ```bash docker run -d \ - --name ccs-dashboard \ + --name ccs \ --restart unless-stopped \ -p 3000:3000 \ -p 8317:8317 \ -e CCS_PORT=3000 \ - -v ccs_home:/home/node/.ccs \ - ghcr.io/kaitranntt/ccs-dashboard:latest + -v ccs_home:/root/.ccs \ + ghcr.io/kaitranntt/ccs:latest ``` -Release-tag images are also published as `ghcr.io/kaitranntt/ccs-dashboard:`. +Or pull the full image with all 4 AI CLIs pre-installed: + +```bash +docker run -d \ + --name ccs \ + --restart unless-stopped \ + -p 3000:3000 \ + -p 8317:8317 \ + -e CCS_PORT=3000 \ + -v ccs_home:/root/.ccs \ + ghcr.io/kaitranntt/ccs:full +``` + +Release-tag images are published as `ghcr.io/kaitranntt/ccs:` (minimal) and `ghcr.io/kaitranntt/ccs:full-` (full). + +### Legacy image (deprecated) + +The `ghcr.io/kaitranntt/ccs-dashboard:latest` image continues building for 2 more releases but +emits a deprecation warning on startup. Migrate to `ccs:latest` at your earliest convenience. ## Prebuilt Image Build Locally diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1cca373d..96948d28 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +echo "[WARN] ghcr.io/kaitranntt/ccs-dashboard is deprecated. Migrate to ghcr.io/kaitranntt/ccs:latest. See https://github.com/kaitranntt/ccs/issues/1251" >&2 + ccs_home_dir="${CCS_HOME_DIR:-/home/node/.ccs}" mkdir -p "$ccs_home_dir" diff --git a/tests/docker/image-size-logic.test.sh b/tests/docker/image-size-logic.test.sh new file mode 100755 index 00000000..63ae23f5 --- /dev/null +++ b/tests/docker/image-size-logic.test.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Unit tests for image-size.sh pass/fail logic using mock docker output. +# Does NOT require a real Docker daemon or pulled images. +# Run: bash tests/docker/image-size-logic.test.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="${SCRIPT_DIR}/image-size.sh" + +PASS=0 +FAIL=0 + +run_test() { + local name="$1" + local expected_exit="$2" + shift 2 + # Remaining args: env vars to set before calling the script + local actual_exit=0 + ( + # Override docker with a mock that returns a fixed size + eval "$@" + bash "$SCRIPT" "mock-image:tag" "$MAX_BYTES" > /dev/null 2>&1 + ) || actual_exit=$? + + if [[ "$actual_exit" -eq "$expected_exit" ]]; then + echo "[OK] ${name}" + (( PASS++ )) || true + else + echo "[X] ${name}: expected exit ${expected_exit}, got ${actual_exit}" + (( FAIL++ )) || true + fi +} + +# ------------------------------------------------------------------ +# We mock docker by injecting a wrapper into PATH that echoes a fixed +# size value for `docker image inspect` and pretends inspect succeeds. +# ------------------------------------------------------------------ + +MOCK_DIR="$(mktemp -d)" +trap 'rm -rf "$MOCK_DIR"' EXIT + +make_mock_docker() { + local size="$1" + cat > "${MOCK_DIR}/docker" < /dev/null 2>&1 || actual_exit=$? + + if [[ "$actual_exit" -eq "$expected_exit" ]]; then + echo "[OK] ${name}" + (( PASS++ )) || true + else + echo "[X] ${name}: expected exit ${expected_exit}, got ${actual_exit}" + (( FAIL++ )) || true + fi +} + +# ------------------------------------------------------------------ +# Tests +# ------------------------------------------------------------------ + +echo "" +echo "Running image-size.sh unit tests..." +echo "" + +# Pass: actual < budget +run_mock_test "pass when actual size < budget" 0 \ + "300000000" "367001600" + +# Pass: actual == budget (boundary) +run_mock_test "pass when actual size == budget (boundary)" 0 \ + "367001600" "367001600" + +# Fail: actual > budget by 1 byte +run_mock_test "fail when actual size exceeds budget by 1 byte" 1 \ + "367001601" "367001600" + +# Fail: actual is much larger than budget +run_mock_test "fail when actual size greatly exceeds budget" 1 \ + "900000000" "629145600" + +# Error: wrong arg count (no args) +actual_exit=0 +bash "$SCRIPT" > /dev/null 2>&1 || actual_exit=$? +if [[ "$actual_exit" -ne 0 ]]; then + echo "[OK] fail when called with no args" + (( PASS++ )) || true +else + echo "[X] fail when called with no args: expected non-zero exit" + (( FAIL++ )) || true +fi + +# Error: non-integer max-bytes +actual_exit=0 +PATH="${MOCK_DIR}:${PATH}" bash "$SCRIPT" "mock-image:tag" "not-a-number" > /dev/null 2>&1 || actual_exit=$? +if [[ "$actual_exit" -ne 0 ]]; then + echo "[OK] fail when max-bytes is not an integer" + (( PASS++ )) || true +else + echo "[X] fail when max-bytes is not an integer: expected non-zero exit" + (( FAIL++ )) || true +fi + +# ------------------------------------------------------------------ +# Summary +# ------------------------------------------------------------------ +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi diff --git a/tests/docker/image-size.sh b/tests/docker/image-size.sh new file mode 100755 index 00000000..be0c0b4c --- /dev/null +++ b/tests/docker/image-size.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Asserts that a Docker image does not exceed a given byte budget. +# +# Usage: image-size.sh +# Exit: 0 on pass, 1 on fail +# +# Examples: +# image-size.sh ghcr.io/kaitranntt/ccs:latest 367001600 # 350 MB +# image-size.sh ghcr.io/kaitranntt/ccs:full 629145600 # 600 MB +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "[X] Usage: $0 " >&2 + exit 1 +fi + +IMAGE="$1" +MAX_BYTES="$2" + +# Validate that max-bytes is a positive integer +if ! [[ "$MAX_BYTES" =~ ^[0-9]+$ ]]; then + echo "[X] max-bytes must be a positive integer, got: ${MAX_BYTES}" >&2 + exit 1 +fi + +# Pull the image if not already present (allows use in a clean CI environment) +if ! docker image inspect "$IMAGE" > /dev/null 2>&1; then + echo "[i] Pulling ${IMAGE}..." >&2 + docker pull "$IMAGE" >&2 +fi + +ACTUAL_BYTES=$(docker image inspect "$IMAGE" --format='{{.Size}}' 2>/dev/null) + +if [[ -z "$ACTUAL_BYTES" ]]; then + echo "[X] Could not inspect image: ${IMAGE}" >&2 + exit 1 +fi + +ACTUAL_MB=$(( ACTUAL_BYTES / 1048576 )) +MAX_MB=$(( MAX_BYTES / 1048576 )) + +if (( ACTUAL_BYTES > MAX_BYTES )); then + echo "[X] Image size check FAILED: ${IMAGE}" >&2 + echo " Actual: ${ACTUAL_BYTES} bytes (${ACTUAL_MB} MB)" >&2 + echo " Budget: ${MAX_BYTES} bytes (${MAX_MB} MB)" >&2 + echo " Excess: $(( ACTUAL_BYTES - MAX_BYTES )) bytes ($(( ACTUAL_MB - MAX_MB )) MB over budget)" >&2 + exit 1 +fi + +echo "[OK] Image size check PASSED: ${IMAGE}" +echo " Actual: ${ACTUAL_BYTES} bytes (${ACTUAL_MB} MB)" +echo " Budget: ${MAX_BYTES} bytes (${MAX_MB} MB)" +echo " Margin: $(( MAX_BYTES - ACTUAL_BYTES )) bytes ($(( MAX_MB - ACTUAL_MB )) MB remaining)"