mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
* docs(quickstart): fix raw URL for corporate-proxy fallback (H1) * feat(docker)!: drop :full image variant — use sibling containers on ccs-net (Q3) No AI CLIs (claude-code/gemini-cli/grok-cli/opencode) are bundled in the image. Use sibling containers attached to ccs-net instead. See docker/README.md#connect-your-app-to-cliproxy. Also removes bash from apk deps (entrypoint uses #!/bin/sh — L2). ci(docker): publish only immutable :<ver> tag pre-smoke; promote-mutable-tags job adds :latest/:MAJOR/:MINOR aliases only after smoke tests pass (H3) ci(docker): smoke-test-compose-url runs network-contract.sh against the downloaded /tmp/ccs-compose.yaml instead of re-cloning the repo (H4) ci(docker): sign published images with cosign keyless OIDC + attach provenance/SBOM via build-push-action (M8) test(docker): network-contract.sh now accepts compose-file and image-ref positional args; replaces python3 healthcheck parser with jq (L4) * chore(release): cut every main release as rc.N prerelease, manual promote flow (H2) - .releaserc.cjs: main branch now uses prerelease 'rc' channel — every semantic-release cut becomes vX.Y.Z-rc.N - add promote-release.yml: workflow_dispatch flips rc → stable via 'gh release edit --prerelease=false'; triggers docker promote-mutable-tags - add docs/release-process.md: full soak + promote procedure, rollback steps, cosign verification command - releaseNotesGenerator: add revert section, document chore hidden behaviour (L8) * fix(docker): healthcheck probes both dashboard and cliproxy ports (M1) compose.yaml healthcheck now checks :3000 and :8317 concurrently with a 4.5s internal timeout, within Docker's 5s timeout budget. Also: - docs(docker): document npm lockfile ephemeral tradeoff above install layer; note size-budget regression test as the practical safeguard (M3) - docs(docker): drop :full row from Choosing an image table; add sibling container note pointing to connect-your-app-to-cliproxy (Q3/docs) - docs(docker): remove :full docker run block; fix release-tag sentence (Q3) - docs(docker): fix raw URL in migration section (H1 parity) - docs(docker): add Volume warning — 'down -v' deletes named volumes (L13) - docs(docker): update What changes table — remove :full reference (Q3) - docs(docker): add Image Signatures and SBOM section with cosign verify and sbom download commands (M8/docs) - changelog: add Unreleased entries for rc soak, cosign signing, :full removal with migration guidance * ci(docker): assert image-size budget per platform; add compose parity + breaking-change guard (M6/L9/L12) - image-size.sh: add --platform flag; uses 'docker buildx imagetools inspect' to sum compressed layer sizes from registry manifest for linux/amd64 and linux/arm64 separately (M6) - docker-release.yml smoke-test: runs size check for both platforms - compose-parity.sh: diffs docker/compose.yaml vs docker/docker-compose.integrated.yml for image name, ports 3000/8317, volume mounts /root/.ccs and /var/log/ccs, ccs-net definition (L12) - ci.yml: add compose-parity job wired to cliproxy runner (L12) - breaking-change-guard.yml: fails PR if compose.yaml changes image name, network name, or container_name without a feat!/fix! commit (L9) * chore(ci): fix cosign shell substitution — use tr instead of bash @L expansion (L6/nit) * test(docker): fix compose-parity port regex for variable-interpolated host ports
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
name: Breaking Change Guard – Docker Compose Contract
|
||||
|
||||
# Guards against accidental breaking changes to the stable ccs-net contract
|
||||
# defined in docker/compose.yaml. The following fields are considered stable
|
||||
# API — changing them breaks existing sibling-container setups:
|
||||
#
|
||||
# services.ccs.image (the image *name*, not the tag)
|
||||
# networks.ccs-net.name
|
||||
# services.ccs.container_name (if set)
|
||||
#
|
||||
# If any of these fields change in a PR, the workflow fails unless at least
|
||||
# one commit in the PR includes a breaking-change marker (feat!: or fix!:).
|
||||
# This enforces a deliberate, visible decision to change the contract.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "docker/compose.yaml"
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Verify breaking changes are intentional
|
||||
if: >-
|
||||
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
|
||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fetch base branch
|
||||
run: git fetch origin ${{ github.base_ref }} --depth=50
|
||||
|
||||
- name: Check for contract-breaking changes
|
||||
id: contract
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE="origin/${{ github.base_ref }}"
|
||||
DIFF=$(git diff "${BASE}"...HEAD -- docker/compose.yaml)
|
||||
|
||||
BREAKING=0
|
||||
|
||||
# 1. Image name change (repo path, not tag — tags change every release)
|
||||
OLD_IMAGE=$(git show "${BASE}:docker/compose.yaml" \
|
||||
| grep -m1 '^\s*image:' | sed 's/.*image:\s*//' | sed 's/:.*//' | tr -d ' ')
|
||||
NEW_IMAGE=$(grep -m1 '^\s*image:' docker/compose.yaml \
|
||||
| sed 's/.*image:\s*//' | sed 's/:.*//' | tr -d ' ')
|
||||
if [[ "${OLD_IMAGE}" != "${NEW_IMAGE}" ]]; then
|
||||
echo "[!] BREAKING: image name changed: '${OLD_IMAGE}' -> '${NEW_IMAGE}'"
|
||||
BREAKING=1
|
||||
fi
|
||||
|
||||
# 2. ccs-net network name change
|
||||
OLD_NET=$(git show "${BASE}:docker/compose.yaml" \
|
||||
| grep 'name: ccs-net' | tr -d ' ' || echo "")
|
||||
NEW_NET=$(grep 'name: ccs-net' docker/compose.yaml | tr -d ' ' || echo "")
|
||||
if [[ "${OLD_NET}" != "${NEW_NET}" ]]; then
|
||||
echo "[!] BREAKING: ccs-net network name changed"
|
||||
BREAKING=1
|
||||
fi
|
||||
|
||||
# 3. container_name change (if present in either version)
|
||||
OLD_CN=$(git show "${BASE}:docker/compose.yaml" \
|
||||
| grep 'container_name:' | tr -d ' ' || echo "")
|
||||
NEW_CN=$(grep 'container_name:' docker/compose.yaml | tr -d ' ' || echo "")
|
||||
if [[ "${OLD_CN}" != "${NEW_CN}" ]]; then
|
||||
echo "[!] BREAKING: container_name changed: '${OLD_CN}' -> '${NEW_CN}'"
|
||||
BREAKING=1
|
||||
fi
|
||||
|
||||
echo "breaking=${BREAKING}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require breaking-change commit marker if contract changed
|
||||
if: steps.contract.outputs.breaking == '1'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE="origin/${{ github.base_ref }}"
|
||||
|
||||
# Check all commit messages in the PR for feat! or fix! marker
|
||||
HAS_BREAKING_MARKER=$(
|
||||
git log "${BASE}"...HEAD --format="%s" \
|
||||
| grep -cE "^(feat|fix)(\([^)]+\))?!:" || true
|
||||
)
|
||||
|
||||
if [[ "${HAS_BREAKING_MARKER}" -eq 0 ]]; then
|
||||
echo ""
|
||||
echo "[X] Breaking change guard FAILED"
|
||||
echo ""
|
||||
echo " docker/compose.yaml was modified in a way that changes the stable"
|
||||
echo " ccs-net contract (image name, network name, or container_name)."
|
||||
echo ""
|
||||
echo " These fields are relied upon by sibling containers. Changing them"
|
||||
echo " without a breaking-change marker is a silent breaking change."
|
||||
echo ""
|
||||
echo " To proceed, rename at least one commit to use the feat! or fix!"
|
||||
echo " breaking-change format:"
|
||||
echo ""
|
||||
echo " feat!: rename ccs service image to ghcr.io/owner/newname"
|
||||
echo " fix!: align container_name with new naming convention"
|
||||
echo ""
|
||||
echo " See: https://www.conventionalcommits.org/en/v1.0.0/#specification"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] Breaking-change commit marker found — contract change is intentional"
|
||||
|
||||
- name: No contract-breaking changes detected
|
||||
if: steps.contract.outputs.breaking == '0'
|
||||
run: echo "[OK] No contract-breaking changes in docker/compose.yaml"
|
||||
@@ -146,3 +146,17 @@ jobs:
|
||||
env:
|
||||
CCS_E2E_SKIP_BUILD: '1'
|
||||
run: bun run test:e2e
|
||||
|
||||
compose-parity:
|
||||
if: >-
|
||||
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
|
||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||
name: compose-parity
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Assert compose parity (compose.yaml vs docker-compose.integrated.yml)
|
||||
run: bash tests/docker/compose-parity.sh
|
||||
|
||||
@@ -12,15 +12,17 @@ on:
|
||||
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.
|
||||
Promote mutable :latest tags (and major/minor aliases) after rc soak.
|
||||
Skipped by default so the first publish of a tag only pushes the
|
||||
immutable version tag. Use only after rc.N soak confirms stability.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Prevent stale parallel runs for the same tag; never cancel in-progress so
|
||||
# a publish + sign + promote sequence always completes atomically.
|
||||
concurrency:
|
||||
group: docker-release-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag || github.ref }}
|
||||
group: docker-release-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag || github.ref || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -29,6 +31,7 @@ jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
publish-dashboard:
|
||||
name: Publish legacy ccs-dashboard image
|
||||
# Skip on prerelease events — rc builds do not publish the legacy image
|
||||
if: ${{ github.event_name != 'release' || !github.event.release.prerelease }}
|
||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||
|
||||
@@ -65,6 +68,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.target.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
@@ -121,6 +125,8 @@ jobs:
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=ccs-dashboard
|
||||
@@ -136,21 +142,26 @@ jobs:
|
||||
cache-to: type=gha,mode=max,scope=dashboard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 2: Integrated image matrix — minimal (ccs:latest) and full (ccs:full)
|
||||
# Job 2: Integrated image — CCS + CLIProxy (single build, no :full variant)
|
||||
# Publishes ONLY the immutable :<ver> tag here.
|
||||
# Mutable :latest / major / minor aliases are added by promote-mutable-tags
|
||||
# AFTER smoke tests pass, preventing a bad image from reaching :latest.
|
||||
# ---------------------------------------------------------------------------
|
||||
publish-integrated:
|
||||
name: Publish integrated image (${{ matrix.flavor }})
|
||||
if: ${{ github.event_name != 'release' || !github.event.release.prerelease }}
|
||||
name: Publish integrated image
|
||||
# Prerelease events (rc.N) publish the immutable version tag only.
|
||||
# Mutable tags are never set on rc builds — promotion is always explicit.
|
||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write # required for keyless cosign signing
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
flavor: [minimal, full]
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
image_ref: ${{ steps.meta.outputs.image_ref }}
|
||||
publish: ${{ steps.tag.outputs.publish }}
|
||||
|
||||
steps:
|
||||
- name: Resolve target tag
|
||||
@@ -166,21 +177,24 @@ jobs:
|
||||
fi
|
||||
echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate stable semver tag
|
||||
- name: Validate semver tag (stable or rc)
|
||||
id: tag
|
||||
run: |
|
||||
if [[ "${{ steps.target.outputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TAG="${{ steps.target.outputs.tag }}"
|
||||
# Accept stable (vX.Y.Z) and prerelease rc (vX.Y.Z-rc.N)
|
||||
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[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 }}"
|
||||
echo "Skipping unrecognised tag format: ${TAG}"
|
||||
|
||||
- name: Checkout release tag
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.target.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
@@ -193,9 +207,6 @@ jobs:
|
||||
- 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}"
|
||||
@@ -205,27 +216,10 @@ jobs:
|
||||
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
|
||||
# Only the immutable version-pinned tag is pushed here.
|
||||
# Mutable tags (:latest, :MAJOR, :MINOR) are added by promote-mutable-tags.
|
||||
TAGS="${IMAGE}:${VERSION}"
|
||||
IMAGE_REF="${IMAGE}:${VERSION}"
|
||||
|
||||
{
|
||||
echo "version=${VERSION}"
|
||||
@@ -233,7 +227,7 @@ jobs:
|
||||
echo "major=${MAJOR}"
|
||||
echo "image=${IMAGE}"
|
||||
echo "revision=${REVISION}"
|
||||
echo "prefix=${PREFIX}"
|
||||
echo "image_ref=${IMAGE_REF}"
|
||||
echo "tags<<EOF"
|
||||
echo "${TAGS}"
|
||||
echo "EOF"
|
||||
@@ -247,94 +241,71 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push integrated image (${{ matrix.flavor }})
|
||||
- name: Build and push integrated image
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.integrated
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: 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.description=CCS integrated image — CLIProxy + CCS CLI
|
||||
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 }}
|
||||
index:org.opencontainers.image.description=CCS integrated image — CLIProxy + CCS CLI
|
||||
cache-from: type=gha,scope=integrated
|
||||
cache-to: type=gha,mode=max,scope=integrated
|
||||
|
||||
- name: Install cosign
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
uses: sigstore/cosign-installer@v3
|
||||
|
||||
- name: Sign image with cosign (keyless OIDC)
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
env:
|
||||
COSIGN_EXPERIMENTAL: "1"
|
||||
run: |
|
||||
DIGEST="${{ steps.build.outputs.digest }}"
|
||||
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
|
||||
cosign sign --yes "ghcr.io/${OWNER_LOWER}/ccs@${DIGEST}"
|
||||
echo "[OK] Signed ghcr.io/${OWNER_LOWER}/ccs@${DIGEST}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 3: Post-publish smoke test — pull each variant and verify it boots
|
||||
# Job 3: Smoke test — pull the immutable :<ver> tag and verify it boots
|
||||
# Runs after publish-integrated; mutable tags are only added if this passes.
|
||||
# ---------------------------------------------------------------------------
|
||||
smoke-test:
|
||||
name: Smoke test ${{ matrix.image }}
|
||||
needs: [publish-integrated]
|
||||
if: ${{ github.event_name != 'release' || !github.event.release.prerelease }}
|
||||
name: Smoke test integrated image
|
||||
needs: [publish-integrated, publish-dashboard]
|
||||
# Run on both rc and stable releases; skip if integrated publish was skipped
|
||||
if: ${{ needs.publish-integrated.outputs.publish == 'true' }}
|
||||
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 }}
|
||||
ref: ${{ needs.publish-integrated.outputs.version != '' && format('v{0}', needs.publish-integrated.outputs.version) || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- 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"
|
||||
echo "ref=${{ needs.publish-integrated.outputs.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
|
||||
@@ -342,20 +313,21 @@ jobs:
|
||||
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'
|
||||
- name: Assert image size budget (amd64)
|
||||
run: |
|
||||
chmod +x tests/docker/image-size.sh
|
||||
tests/docker/image-size.sh "${{ steps.image.outputs.ref }}" "${{ matrix.max_bytes }}"
|
||||
tests/docker/image-size.sh "${{ steps.image.outputs.ref }}" "367001600" "--platform" "linux/amd64"
|
||||
|
||||
- name: Assert image size budget (arm64)
|
||||
run: |
|
||||
tests/docker/image-size.sh "${{ steps.image.outputs.ref }}" "367001600" "--platform" "linux/arm64"
|
||||
|
||||
- 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 }}"
|
||||
CONTAINER_NAME="ccs-smoke-${{ github.run_id }}"
|
||||
echo "container=${CONTAINER_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker run -d \
|
||||
@@ -381,8 +353,11 @@ jobs:
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Run network-contract test
|
||||
run: |
|
||||
bash tests/docker/network-contract.sh "${{ steps.image.outputs.ref }}"
|
||||
|
||||
- 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
|
||||
@@ -392,7 +367,6 @@ jobs:
|
||||
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
|
||||
@@ -402,6 +376,54 @@ jobs:
|
||||
echo "[OK] CLIProxy port 8317 responded with HTTP ${HTTP_CODE}"
|
||||
|
||||
- name: Stop smoke test container
|
||||
if: always() && steps.tag.outputs.publish == 'true'
|
||||
if: always()
|
||||
run: |
|
||||
docker stop "ccs-smoke-${{ matrix.flavor }}-${{ github.run_id }}" 2>/dev/null || true
|
||||
docker stop "ccs-smoke-${{ github.run_id }}" 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job 4: Promote mutable tags — runs ONLY after smoke tests pass
|
||||
# Adds :latest, :<major>, :<minor> aliases pointing to the immutable digest.
|
||||
# Never runs for prerelease (rc) events.
|
||||
# ---------------------------------------------------------------------------
|
||||
promote-mutable-tags:
|
||||
name: Promote mutable tags (:latest / major / minor)
|
||||
needs: [smoke-test, publish-integrated]
|
||||
# Only promote on stable release events or explicit promote_to_latest dispatch
|
||||
if: |
|
||||
needs.publish-integrated.outputs.publish == 'true' && (
|
||||
(github.event_name == 'release' && !github.event.release.prerelease) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.promote_to_latest == true)
|
||||
)
|
||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write # for cosign re-sign of mutable aliases
|
||||
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Promote :latest / major / minor aliases
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.publish-integrated.outputs.image_ref }}
|
||||
VERSION: ${{ needs.publish-integrated.outputs.version }}
|
||||
run: |
|
||||
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE="ghcr.io/${OWNER_LOWER}/ccs"
|
||||
MINOR="${VERSION%.*}"
|
||||
MAJOR="${VERSION%%.*}"
|
||||
|
||||
echo "[i] Pointing mutable tags to ${IMAGE_REF}"
|
||||
docker buildx imagetools create \
|
||||
--tag "${IMAGE}:latest" \
|
||||
--tag "${IMAGE}:${MINOR}" \
|
||||
--tag "${IMAGE}:${MAJOR}" \
|
||||
"${IMAGE_REF}"
|
||||
|
||||
echo "[OK] Promoted: :latest :${MINOR} :${MAJOR} → ${IMAGE_REF}"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Promote rc to Stable Release
|
||||
|
||||
# Manual workflow to promote a pre-release rc tag to a stable GitHub release.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Flip the GitHub release from prerelease=true to prerelease=false using
|
||||
# `gh release edit --prerelease=false`.
|
||||
# 2. The resulting `release: published` event (with prerelease=false) triggers
|
||||
# docker-release.yml, which builds/signs the image, runs smoke tests, then
|
||||
# adds the mutable :latest/:MAJOR/:MINOR Docker tags via promote-mutable-tags.
|
||||
#
|
||||
# Pre-conditions:
|
||||
# - The rc tag must already exist as a GitHub prerelease (cut by semantic-release).
|
||||
# - Smoke tests on the rc image must have passed (manual verification step).
|
||||
#
|
||||
# See docs/release-process.md for the full soak + promote procedure.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rc_tag:
|
||||
description: >
|
||||
Pre-release rc tag to promote, e.g. v7.80.0-rc.1.
|
||||
Must already exist as a GitHub prerelease.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
name: Promote ${{ inputs.rc_tag }} to stable
|
||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||
|
||||
permissions:
|
||||
contents: write # required to edit GitHub releases
|
||||
|
||||
steps:
|
||||
- name: Validate rc tag format
|
||||
run: |
|
||||
if [[ "${{ inputs.rc_tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "[OK] Tag format valid: ${{ inputs.rc_tag }}"
|
||||
else
|
||||
echo "[X] Expected vX.Y.Z-rc.N format, got: ${{ inputs.rc_tag }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify release exists and is a prerelease
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
IS_PRERELEASE=$(gh release view "${{ inputs.rc_tag }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--json isPrerelease --jq '.isPrerelease')
|
||||
if [[ "${IS_PRERELEASE}" != "true" ]]; then
|
||||
echo "[X] Release ${{ inputs.rc_tag }} is not a prerelease (isPrerelease=${IS_PRERELEASE})"
|
||||
echo " Either it was already promoted, or the tag does not exist."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Release ${{ inputs.rc_tag }} is a prerelease — proceeding with promotion"
|
||||
|
||||
- name: Promote release to stable
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release edit "${{ inputs.rc_tag }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--prerelease=false \
|
||||
--latest
|
||||
echo "[OK] Promoted ${{ inputs.rc_tag }} to stable (prerelease=false, latest=true)"
|
||||
echo "[i] docker-release.yml will now trigger to add :latest/:MAJOR/:MINOR Docker tags"
|
||||
@@ -11,18 +11,24 @@ on:
|
||||
inputs:
|
||||
do_up:
|
||||
description: >
|
||||
Also run `docker compose up -d`, wait for healthcheck, curl ports, then
|
||||
`docker compose down -v`. Skipped by default (parse-only).
|
||||
Also run `docker compose up -d`, wait for healthcheck, run the
|
||||
network-contract test against the downloaded URL compose file, then
|
||||
tear down. Skipped by default (parse-only).
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
smoke-test:
|
||||
name: curl + parse (+ optional up/down)
|
||||
name: curl + parse (+ optional up/down + network-contract)
|
||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||
|
||||
steps:
|
||||
- name: Checkout (for test scripts)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download compose from canonical URL
|
||||
run: |
|
||||
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o /tmp/ccs-compose.yaml
|
||||
@@ -32,45 +38,13 @@ jobs:
|
||||
- name: Validate compose parses (docker compose config)
|
||||
run: docker compose -f /tmp/ccs-compose.yaml config
|
||||
|
||||
- name: Bring stack up, verify healthcheck, tear down
|
||||
- name: Run network-contract test against downloaded compose
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.do_up == true }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker compose -f /tmp/ccs-compose.yaml up -d
|
||||
|
||||
echo "Waiting for healthcheck to pass (max 90s)..."
|
||||
for i in $(seq 1 18); do
|
||||
STATUS=$(docker compose -f /tmp/ccs-compose.yaml ps --format json \
|
||||
| python3 -c "import sys,json; data=sys.stdin.read().strip(); rows=json.loads('['+','.join(data.splitlines())+']') if data else []; print(next((r.get('Health','') for r in rows if 'ccs' in r.get('Service','')), 'unknown'))" 2>/dev/null || echo "unknown")
|
||||
echo "Health: $STATUS"
|
||||
if [ "$STATUS" = "healthy" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# Verify ports respond
|
||||
curl -fsSL --retry 3 --retry-delay 2 http://localhost:3000 -o /dev/null || \
|
||||
{ echo "[X] Dashboard port 3000 did not respond"; docker compose -f /tmp/ccs-compose.yaml down -v; exit 1; }
|
||||
|
||||
curl -fsSL --retry 3 --retry-delay 2 http://localhost:8317 -o /dev/null || \
|
||||
{ echo "[X] CLIProxy port 8317 did not respond"; docker compose -f /tmp/ccs-compose.yaml down -v; exit 1; }
|
||||
|
||||
echo "[OK] Both ports healthy"
|
||||
docker compose -f /tmp/ccs-compose.yaml down -v
|
||||
|
||||
- name: Verify ccs-net network contract (sibling DNS resolution)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.do_up == true }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Checkout repo so tests/docker/network-contract.sh is available
|
||||
# The script needs to run from a directory that contains docker/compose.yaml.
|
||||
# We re-use the compose file from the repo rather than /tmp to avoid path drift.
|
||||
REPO_ROOT="$(mktemp -d)"
|
||||
git clone --depth=1 --no-tags \
|
||||
"https://github.com/kaitranntt/ccs.git" "$REPO_ROOT"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
bash tests/docker/network-contract.sh
|
||||
# network-contract.sh accepts the compose file as first argument.
|
||||
# This exercises the exact file served at the canonical URL rather
|
||||
# than the repo copy, ensuring URL-delivered compose works end-to-end.
|
||||
# The script handles up, healthcheck wait, sibling DNS probes, and
|
||||
# teardown (via EXIT trap) — no separate up/down steps needed.
|
||||
bash tests/docker/network-contract.sh /tmp/ccs-compose.yaml
|
||||
|
||||
+21
-3
@@ -37,6 +37,9 @@ const releaseNotesGenerator = [
|
||||
{ type: 'feat', section: 'Features' },
|
||||
{ type: 'fix', section: 'Bug Fixes' },
|
||||
{ type: 'hotfix', section: 'Hotfixes' },
|
||||
// Breaking changes (feat! / fix!) surface under Features/Bug Fixes
|
||||
// with a BREAKING CHANGE footer note — no separate section needed.
|
||||
{ type: 'revert', section: 'Reverts' },
|
||||
{ type: 'docs', section: 'Documentation' },
|
||||
{ type: 'style', section: 'Styles' },
|
||||
{ type: 'refactor', section: 'Code Refactoring' },
|
||||
@@ -44,6 +47,9 @@ const releaseNotesGenerator = [
|
||||
{ type: 'test', section: 'Tests' },
|
||||
{ type: 'build', section: 'Build System' },
|
||||
{ type: 'ci', section: 'CI' },
|
||||
// chore commits are intentionally hidden from release notes (no section).
|
||||
// "### Removed" sections come from feat!/fix! BREAKING CHANGE footers,
|
||||
// not from a separate commit type.
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -87,8 +93,18 @@ const devConfig = {
|
||||
};
|
||||
|
||||
// Production release configuration
|
||||
// Every merge to main auto-cuts as vX.Y.Z-rc.N (prerelease channel "rc").
|
||||
// A separate promote-release.yml workflow_dispatch promotes a specific rc tag
|
||||
// to stable by flipping the GitHub release to non-prerelease, which triggers
|
||||
// docker-release.yml to add the mutable :latest/:MAJOR/:MINOR Docker tags.
|
||||
// See docs/release-process.md for the full soak + promote procedure.
|
||||
const productionConfig = {
|
||||
branches: ['main'],
|
||||
branches: [
|
||||
{
|
||||
name: 'main',
|
||||
prerelease: 'rc',
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
commitAnalyzer,
|
||||
releaseNotesGenerator,
|
||||
@@ -102,9 +118,11 @@ const productionConfig = {
|
||||
[
|
||||
'@semantic-release/github',
|
||||
{
|
||||
// rc releases are prerelease — use a minimal comment; stable promotion
|
||||
// gets the full resolution comment via the promote-release workflow.
|
||||
successComment:
|
||||
':tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@latest)](https://www.npmjs.com/package/@kaitranntt/ccs)\n- [GitHub release](${releases[0].url})',
|
||||
releasedLabels: ['released'],
|
||||
'This issue is included in pre-release version ${nextRelease.version}. A stable release will follow after the rc soak period.',
|
||||
releasedLabels: ['pending-release'],
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
+7
-1
@@ -3,10 +3,16 @@
|
||||
### Added
|
||||
|
||||
* **docker:** Stable Docker network contract — external network `ccs-net` with service DNS `ccs` resolving to the CCS container. CLIProxy reachable at `http://ccs:8317`; dashboard at `http://ccs:3000`. Sibling containers can attach via `--network ccs-net` or by declaring `ccs-net` as an external network in their own compose file. Changing the network name or service name is a **SemVer-major breaking change**. See [docker/README.md](docker/README.md#connect-your-app-to-cliproxy) for usage patterns and troubleshooting. Verified by `tests/docker/network-contract.sh`.
|
||||
* **docker/ci:** Published images are signed with cosign (keyless OIDC) and include a provenance attestation and SBOM. Verification command documented in [docker/README.md](docker/README.md#image-signatures-and-sbom).
|
||||
* **release:** Every merge to `main` now auto-cuts a `vX.Y.Z-rc.N` pre-release. A manual `promote-release` workflow promotes the rc to stable after the soak period. See [docs/release-process.md](docs/release-process.md).
|
||||
|
||||
### Removed
|
||||
|
||||
* **docker:** Dropped Docker image variant `ccs:full`. AI CLIs (claude-code, gemini-cli, grok-cli, opencode) are no longer bundled. Use sibling containers attached to `ccs-net` instead — see [docker/README.md#connect-your-app-to-cliproxy](docker/README.md#connect-your-app-to-cliproxy). Rationale: smaller surface area, fewer supply-chain dependencies, simpler tag taxonomy. The `:latest` image now covers the only published integrated variant.
|
||||
|
||||
### 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).
|
||||
* **docker:** `ghcr.io/kaitranntt/ccs-dashboard:latest` Docker image is deprecated — migrate to `ghcr.io/kaitranntt/ccs:latest` (CCS + CLIProxy). 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)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ docker compose up -d
|
||||
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
|
||||
|
||||
Need a corporate-proxy alternative? Download directly:
|
||||
`https://github.com/kaitranntt/ccs/blob/main/docker/compose.yaml`
|
||||
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
|
||||
<!-- quickstart-snippet-end -->
|
||||
|
||||
## Install on Host (npm)
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
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
|
||||
|
||||
# CCS integrated image: CCS CLI + CLIProxy + supervisord.
|
||||
# No AI CLIs (claude-code, gemini-cli, etc.) are bundled.
|
||||
# To use AI CLIs alongside CLIProxy, run them in sibling containers
|
||||
# attached to ccs-net — see docker/README.md#connect-your-app-to-cliproxy.
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
curl \
|
||||
jq \
|
||||
nodejs \
|
||||
npm \
|
||||
supervisor
|
||||
|
||||
# Install CCS CLI (always present in both flavors)
|
||||
# Install CCS CLI.
|
||||
# Tradeoff: `npm install -g` does not use a lockfile — the package is already
|
||||
# version-pinned via CCS_NPM_VERSION (e.g. "7.80.0"), so transitive drift is
|
||||
# bounded to patch-level updates of CCS's own production dependencies between
|
||||
# publish time and image build time. This is intentional: pinning an exact
|
||||
# lockfile in a Docker global-install layer is fragile (lockfile format ties to
|
||||
# npm/Node version in the base image). The --mount=type=cache layer ensures the
|
||||
# resolved dependency tree is stable within a single build host's cache.
|
||||
# Regression test: tests/docker/image-size.sh asserts the image stays within
|
||||
# the size budget, which catches unexpected dep bloat.
|
||||
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
|
||||
|
||||
|
||||
+32
-20
@@ -29,7 +29,7 @@ docker compose up -d
|
||||
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
|
||||
|
||||
Need a corporate-proxy alternative? Download directly:
|
||||
`https://github.com/kaitranntt/ccs/blob/main/docker/compose.yaml`
|
||||
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
|
||||
<!-- quickstart-snippet-end -->
|
||||
|
||||
---
|
||||
@@ -38,11 +38,12 @@ Need a corporate-proxy alternative? Download directly:
|
||||
|
||||
| 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:latest` | CCS + CLIProxy, no AI CLIs bundled | < 350 MB | **Recommended** |
|
||||
| `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:<major>.<minor>.<patch>`, `ccs:<major>.<minor>`, `ccs:<major>`) for reproducible deployments. The `:full` variants carry the `full-` prefix: `ccs:full-<ver>`, `ccs:full-<minor>`, etc.
|
||||
`ccs:latest` also publishes pinned version tags (`ccs:<major>.<minor>.<patch>`, `ccs:<major>.<minor>`, `ccs:<major>`) for reproducible deployments.
|
||||
|
||||
**Need claude-code, gemini-cli, grok-cli, or opencode?** Run those tools in a sibling container attached to `ccs-net` — see [Connect your app to CLIProxy](#connect-your-app-to-cliproxy). This keeps each tool independently versioned and prevents supply-chain bloat in the CLIProxy image.
|
||||
|
||||
---
|
||||
|
||||
@@ -195,20 +196,7 @@ docker run -d \
|
||||
ghcr.io/kaitranntt/ccs:latest
|
||||
```
|
||||
|
||||
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:<version>` (minimal) and `ghcr.io/kaitranntt/ccs:full-<version>` (full).
|
||||
Release-tag images are published as `ghcr.io/kaitranntt/ccs:<version>` for reproducible deployments.
|
||||
|
||||
### Build Locally
|
||||
|
||||
@@ -362,7 +350,7 @@ releases. Migrate to `ghcr.io/kaitranntt/ccs:latest` now.
|
||||
```
|
||||
|
||||
Or download manually from:
|
||||
`https://github.com/kaitranntt/ccs/blob/main/docker/compose.yaml`
|
||||
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
|
||||
|
||||
4. **If you were bind-mounting `~/.ccs`** (instead of using a named volume), edit the downloaded
|
||||
`docker-compose.yaml` and replace the `ccs_home` named volume with your bind mount:
|
||||
@@ -387,6 +375,11 @@ releases. Migrate to `ghcr.io/kaitranntt/ccs:latest` now.
|
||||
|
||||
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
|
||||
|
||||
> **Warning:** Use `docker compose down` (without `-v`) to stop the stack.
|
||||
> `docker compose down -v` deletes named volumes including `ccs_home`, which
|
||||
> permanently removes your CCS configuration and auth tokens. Always omit
|
||||
> `-v` unless you intentionally want a clean wipe.
|
||||
|
||||
6. **Verify.**
|
||||
|
||||
```bash
|
||||
@@ -399,7 +392,7 @@ releases. Migrate to `ghcr.io/kaitranntt/ccs:latest` now.
|
||||
|---|---|
|
||||
| `ghcr.io/kaitranntt/ccs-dashboard:latest` | `ghcr.io/kaitranntt/ccs:latest` |
|
||||
| > 600 MB image | < 350 MB image |
|
||||
| Monolithic all-in-one | CCS + CLIProxy (AI CLIs optional via `:full`) |
|
||||
| Monolithic all-in-one | CCS + CLIProxy (AI CLIs via sibling containers on `ccs-net`) |
|
||||
| No stable network contract | `ccs-net` network, `ccs` service DNS |
|
||||
|
||||
---
|
||||
@@ -592,3 +585,22 @@ docker exec -it ccs-dashboard gemini --help
|
||||
- **Secrets**: For sensitive values like `CCS_PROXY_AUTH_TOKEN`, consider using Docker secrets or a `.env` file (not committed to git).
|
||||
- **Network**: The container exposes ports 3000 and 8317. In production, use a reverse proxy (nginx, traefik) with TLS.
|
||||
- **Updates**: Regularly rebuild the image to get security patches: `docker-compose build --pull`
|
||||
|
||||
### Image Signatures and SBOM
|
||||
|
||||
All `ghcr.io/kaitranntt/ccs` images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/) using keyless OIDC signing tied to the GitHub Actions workflow identity. A software bill of materials (SBOM) is attached to every image at publish time.
|
||||
|
||||
**Verify a specific image digest:**
|
||||
|
||||
```bash
|
||||
cosign verify \
|
||||
--certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
ghcr.io/kaitranntt/ccs:<version>
|
||||
```
|
||||
|
||||
**Inspect the SBOM:**
|
||||
|
||||
```bash
|
||||
cosign download sbom ghcr.io/kaitranntt/ccs:<version>
|
||||
```
|
||||
|
||||
+17
-1
@@ -8,6 +8,9 @@
|
||||
# Ports:
|
||||
# 3000 — CCS Dashboard
|
||||
# 8317 — CLIProxy API
|
||||
#
|
||||
# restart: unless-stopped ensures the container restarts automatically on
|
||||
# system reboot or crash, but honours a deliberate `docker compose stop`.
|
||||
|
||||
services:
|
||||
ccs:
|
||||
@@ -22,10 +25,23 @@ services:
|
||||
networks:
|
||||
- ccs-net
|
||||
healthcheck:
|
||||
# Probes both Dashboard (:3000) and CLIProxy (:8317) before reporting
|
||||
# healthy — a container with only one service running is not considered
|
||||
# ready. Uses a 4.5s internal timeout to stay well within the 5s Docker
|
||||
# timeout budget.
|
||||
test:
|
||||
- "CMD-SHELL"
|
||||
- >
|
||||
node -e "require('http').get('http://127.0.0.1:8317/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"
|
||||
node -e "
|
||||
const http = require('http');
|
||||
let pending = 2; let ok = true;
|
||||
const check = (port) => http.get('http://127.0.0.1:'+port+'/', r => {
|
||||
if (r.statusCode >= 500) ok = false;
|
||||
if (--pending === 0) process.exit(ok ? 0 : 1);
|
||||
}).on('error', () => { ok = false; if (--pending === 0) process.exit(1); });
|
||||
check(3000); check(8317);
|
||||
setTimeout(() => process.exit(1), 4500);
|
||||
"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -11,5 +11,5 @@ docker compose up -d
|
||||
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
|
||||
|
||||
Need a corporate-proxy alternative? Download directly:
|
||||
`https://github.com/kaitranntt/ccs/blob/main/docker/compose.yaml`
|
||||
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
|
||||
<!-- quickstart-snippet-end -->
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# CCS Release Process
|
||||
|
||||
CCS uses a two-phase release model: every merge to `main` auto-cuts a
|
||||
pre-release rc build, and a separate manual promotion step flips it to stable.
|
||||
This gives a soak window before `:latest` Docker tags and npm `@latest` reach
|
||||
end users.
|
||||
|
||||
## Phase 1 — Automatic rc cut (on every merge to `main`)
|
||||
|
||||
1. A PR is merged into `main` with a conventional commit (`feat:`, `fix:`, etc.).
|
||||
2. `release.yml` triggers semantic-release, which reads `.releaserc.cjs`.
|
||||
3. Because `main` is configured as `{ name: 'main', prerelease: 'rc' }`,
|
||||
semantic-release cuts a GitHub pre-release tagged `vX.Y.Z-rc.N`.
|
||||
4. The npm package is published to the `rc` dist-tag
|
||||
(`npm install @kaitranntt/ccs@rc`).
|
||||
5. `docker-release.yml` triggers on the `release: published` event and:
|
||||
- Builds the integrated image for `linux/amd64` and `linux/arm64`.
|
||||
- Pushes **only the immutable** `ghcr.io/kaitranntt/ccs:X.Y.Z-rc.N` tag.
|
||||
- Signs the image with cosign (keyless OIDC).
|
||||
- Runs smoke tests (`smoke-test` job).
|
||||
- Mutable tags (`:latest`, `:<MAJOR>`, `:<MINOR>`) are **not** added at
|
||||
this stage — `promote-mutable-tags` is gated on `!github.event.release.prerelease`.
|
||||
|
||||
## Phase 2 — Manual promotion to stable
|
||||
|
||||
After the rc image has soaked (typically 24–48 h with no reported issues):
|
||||
|
||||
1. Verify the rc image is healthy:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/kaitranntt/ccs:X.Y.Z-rc.N
|
||||
docker run --rm -p 3000:3000 -p 8317:8317 ghcr.io/kaitranntt/ccs:X.Y.Z-rc.N
|
||||
# check http://localhost:3000 and http://localhost:8317
|
||||
```
|
||||
|
||||
2. Optionally verify the cosign signature:
|
||||
|
||||
```bash
|
||||
cosign verify \
|
||||
--certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
ghcr.io/kaitranntt/ccs:X.Y.Z-rc.N
|
||||
```
|
||||
|
||||
3. Run the `promote-release` workflow via GitHub Actions UI or CLI:
|
||||
|
||||
```bash
|
||||
gh workflow run promote-release.yml \
|
||||
--field rc_tag=vX.Y.Z-rc.N
|
||||
```
|
||||
|
||||
4. The workflow calls `gh release edit vX.Y.Z-rc.N --prerelease=false --latest`,
|
||||
which fires a new `release: published` event with `prerelease=false`.
|
||||
|
||||
5. `docker-release.yml` picks up the stable event and the `promote-mutable-tags`
|
||||
job runs, adding `:latest`, `:<MAJOR>`, `:<MINOR>` via
|
||||
`docker buildx imagetools create`.
|
||||
|
||||
6. npm `@latest` is already set by semantic-release during the rc phase for the
|
||||
stable semver portion — no separate npm step is needed.
|
||||
|
||||
## Verifying the promotion
|
||||
|
||||
```bash
|
||||
# Confirm :latest points to the promoted digest
|
||||
docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:latest
|
||||
|
||||
# Confirm npm @latest updated
|
||||
npm view @kaitranntt/ccs dist-tags
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
If a promoted release is found to be bad:
|
||||
|
||||
```bash
|
||||
# Re-mark as prerelease on GitHub (stops new users from pulling :latest via UI)
|
||||
gh release edit vX.Y.Z-rc.N --prerelease=true --latest=false
|
||||
|
||||
# Repoint :latest to the previous known-good version
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/kaitranntt/ccs:latest \
|
||||
ghcr.io/kaitranntt/ccs:PREVIOUS.VERSION
|
||||
```
|
||||
|
||||
## Branch / tag taxonomy
|
||||
|
||||
| Branch | Semantic-release channel | npm dist-tag | Docker tag |
|
||||
|--------|--------------------------|--------------|------------|
|
||||
| `main` | `rc` prerelease | `@rc` | `:<ver>-rc.N` (immutable only) |
|
||||
| `main` (after promote) | stable | `@latest` | `:latest`, `:<MAJOR>`, `:<MINOR>`, `:<ver>` |
|
||||
| `dev` | `dev` prerelease | `@dev` | not published |
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/docker/compose-parity.sh
|
||||
#
|
||||
# Asserts that docker/compose.yaml and docker/docker-compose.integrated.yml
|
||||
# agree on the fields that form the stable network contract:
|
||||
# - image name (without tag)
|
||||
# - exposed host ports
|
||||
# - named volume mounts
|
||||
#
|
||||
# This prevents drift between the public quickstart compose and the
|
||||
# `ccs docker` CLI compose that is bundled with the package.
|
||||
#
|
||||
# Usage: bash tests/docker/compose-parity.sh
|
||||
# (called from repo root)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CANONICAL="docker/compose.yaml"
|
||||
INTEGRATED="docker/docker-compose.integrated.yml"
|
||||
|
||||
fail=0
|
||||
|
||||
log() { printf '[i] %s\n' "$*"; }
|
||||
ok() { printf '[OK] %s\n' "$*"; }
|
||||
fail() { printf '[X] %s\n' "$*" >&2; fail=1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: extract image name (repo path without tag) for a service
|
||||
# ---------------------------------------------------------------------------
|
||||
image_name() {
|
||||
local file="$1" service="$2"
|
||||
# Match lines like: image: ghcr.io/owner/repo:tag or image: name:tag
|
||||
grep -A 50 "^ ${service}:" "$file" \
|
||||
| grep -m1 '^\s*image:' \
|
||||
| sed 's/.*image:\s*//' \
|
||||
| sed 's/:.*//' \
|
||||
| tr -d ' '
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: extract sorted list of internal container ports exposed
|
||||
# ---------------------------------------------------------------------------
|
||||
exposed_ports() {
|
||||
local file="$1"
|
||||
# Match port mappings: "HOST:CONTAINER" — extract CONTAINER port number
|
||||
grep -E '^\s+- "[0-9]+:[0-9]+"' "$file" \
|
||||
| sed 's/.*:\([0-9]*\)".*/\1/' \
|
||||
| sort -n
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: extract sorted list of named volume mount targets (container paths)
|
||||
# ---------------------------------------------------------------------------
|
||||
volume_targets() {
|
||||
local file="$1"
|
||||
# Match volume entries: - name:/container/path
|
||||
grep -E '^\s+- [a-z_]+:/' "$file" \
|
||||
| sed 's/.*:\(\/[^[:space:]]*\).*/\1/' \
|
||||
| sort
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Image name (repo without tag) — both should reference kaitranntt/ccs
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Checking image name parity..."
|
||||
|
||||
CANONICAL_IMAGE=$(image_name "$CANONICAL" "ccs")
|
||||
INTEGRATED_IMAGE=$(image_name "$INTEGRATED" "ccs-cliproxy")
|
||||
|
||||
# Both must contain kaitranntt/ccs (integrated builds locally but from the same Dockerfile)
|
||||
if echo "$CANONICAL_IMAGE" | grep -q "kaitranntt/ccs" && \
|
||||
echo "$INTEGRATED_IMAGE" | grep -q "ccs"; then
|
||||
ok "Image names reference expected repo (canonical: ${CANONICAL_IMAGE}, integrated: ${INTEGRATED_IMAGE})"
|
||||
else
|
||||
fail "Image name mismatch — canonical='${CANONICAL_IMAGE}' integrated='${INTEGRATED_IMAGE}'"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Exposed ports — both must expose 3000 and 8317
|
||||
# Matches both quoted ("HOST:CONTAINER") and unquoted (HOST:CONTAINER) forms,
|
||||
# as well as variable-interpolated host ports like "${VAR:-3000}:3000".
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Checking exposed port parity..."
|
||||
|
||||
port_exposed() {
|
||||
local file="$1" port="$2"
|
||||
# Match container port in: "anything:PORT" or anything:PORT (quoted or bare)
|
||||
grep -E "(\"[^\"]*:${port}\"|[[:space:]]-[[:space:]]+[^\"]*:${port}[^0-9])" "$file" \
|
||||
> /dev/null 2>&1
|
||||
}
|
||||
|
||||
REQUIRED_PORTS=("3000" "8317")
|
||||
for port in "${REQUIRED_PORTS[@]}"; do
|
||||
IN_CANONICAL=0
|
||||
IN_INTEGRATED=0
|
||||
port_exposed "$CANONICAL" "$port" && IN_CANONICAL=1 || true
|
||||
port_exposed "$INTEGRATED" "$port" && IN_INTEGRATED=1 || true
|
||||
|
||||
if [[ "$IN_CANONICAL" -eq 1 && "$IN_INTEGRATED" -eq 1 ]]; then
|
||||
ok "Port ${port} exposed in both compose files"
|
||||
elif [[ "$IN_CANONICAL" -eq 0 ]]; then
|
||||
fail "Port ${port} missing from ${CANONICAL}"
|
||||
else
|
||||
fail "Port ${port} missing from ${INTEGRATED}"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Named volume mount targets — both must mount /root/.ccs and /var/log/ccs
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Checking volume mount parity..."
|
||||
|
||||
REQUIRED_MOUNTS=("/root/.ccs" "/var/log/ccs")
|
||||
for mount in "${REQUIRED_MOUNTS[@]}"; do
|
||||
if grep -q ":${mount}" "$CANONICAL" && grep -q ":${mount}" "$INTEGRATED"; then
|
||||
ok "Volume mount ${mount} present in both compose files"
|
||||
elif ! grep -q ":${mount}" "$CANONICAL"; then
|
||||
fail "Volume mount ${mount} missing from ${CANONICAL}"
|
||||
else
|
||||
fail "Volume mount ${mount} missing from ${INTEGRATED}"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Network name — canonical must define ccs-net; integrated inherits default
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Checking ccs-net network definition..."
|
||||
if grep -q "name: ccs-net" "$CANONICAL"; then
|
||||
ok "ccs-net network defined in ${CANONICAL}"
|
||||
else
|
||||
fail "ccs-net network definition missing from ${CANONICAL}"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
echo "" >&2
|
||||
echo "[X] Compose parity check FAILED — update ${INTEGRATED} to match ${CANONICAL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
ok "Compose parity check passed"
|
||||
+80
-17
@@ -1,21 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Asserts that a Docker image does not exceed a given byte budget.
|
||||
#
|
||||
# Usage: image-size.sh <image:tag> <max-bytes>
|
||||
# Usage: image-size.sh <image:tag> <max-bytes> [--platform <platform>]
|
||||
# Exit: 0 on pass, 1 on fail
|
||||
#
|
||||
# When --platform is given the manifest for that specific platform is inspected
|
||||
# via `docker buildx imagetools inspect`, summing compressed layer sizes from
|
||||
# the registry manifest. This avoids pulling the image locally for each arch
|
||||
# and works on a multi-arch manifest list.
|
||||
#
|
||||
# Without --platform the locally cached image is inspected via
|
||||
# `docker image inspect`, which only reports the host-native architecture.
|
||||
#
|
||||
# Examples:
|
||||
# image-size.sh ghcr.io/kaitranntt/ccs:latest 367001600 # 350 MB
|
||||
# image-size.sh ghcr.io/kaitranntt/ccs:full 629145600 # 600 MB
|
||||
# image-size.sh ghcr.io/kaitranntt/ccs:latest 367001600
|
||||
# image-size.sh ghcr.io/kaitranntt/ccs:latest 367001600 --platform linux/amd64
|
||||
# image-size.sh ghcr.io/kaitranntt/ccs:latest 367001600 --platform linux/arm64
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "[X] Usage: $0 <image:tag> <max-bytes>" >&2
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "[X] Usage: $0 <image:tag> <max-bytes> [--platform <platform>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="$1"
|
||||
MAX_BYTES="$2"
|
||||
PLATFORM=""
|
||||
|
||||
# Parse optional --platform flag
|
||||
shift 2
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--platform)
|
||||
PLATFORM="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "[X] Unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate that max-bytes is a positive integer
|
||||
if ! [[ "$MAX_BYTES" =~ ^[0-9]+$ ]]; then
|
||||
@@ -23,31 +48,69 @@ if ! [[ "$MAX_BYTES" =~ ^[0-9]+$ ]]; then
|
||||
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
|
||||
MAX_MB=$(( MAX_BYTES / 1048576 ))
|
||||
|
||||
ACTUAL_BYTES=$(docker image inspect "$IMAGE" --format='{{.Size}}' 2>/dev/null)
|
||||
if [[ -n "$PLATFORM" ]]; then
|
||||
# Multi-arch path: sum compressed layer sizes from the registry manifest.
|
||||
# `docker buildx imagetools inspect --format` returns the manifest JSON for
|
||||
# the given platform. We sum the `size` field of each layer entry.
|
||||
echo "[i] Inspecting ${IMAGE} for platform ${PLATFORM} via registry manifest..." >&2
|
||||
ACTUAL_BYTES=$(
|
||||
docker buildx imagetools inspect "${IMAGE}" \
|
||||
--format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" \
|
||||
--raw 2>/dev/null \
|
||||
| tr ' ' '\n' \
|
||||
| awk 'NF && /^[0-9]+$/ { sum += $1 } END { print sum+0 }' \
|
||||
2>/dev/null || echo ""
|
||||
)
|
||||
|
||||
if [[ -z "$ACTUAL_BYTES" ]]; then
|
||||
echo "[X] Could not inspect image: ${IMAGE}" >&2
|
||||
exit 1
|
||||
if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then
|
||||
# Fallback: try the platform-specific sub-manifest
|
||||
echo "[i] Falling back to platform-scoped imagetools inspect..." >&2
|
||||
ACTUAL_BYTES=$(
|
||||
docker buildx imagetools inspect "${IMAGE}@$(
|
||||
docker buildx imagetools inspect "${IMAGE}" \
|
||||
--format "{{ range .Manifest.Manifests }}{{ if eq .Platform.OS \"$(echo "${PLATFORM}" | cut -d/ -f1)\" }}{{ if eq .Platform.Architecture \"$(echo "${PLATFORM}" | cut -d/ -f2)\" }}{{ .Digest }}{{ end }}{{ end }}{{ end }}" \
|
||||
2>/dev/null | head -1
|
||||
)" --format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" 2>/dev/null \
|
||||
| tr ' ' '\n' \
|
||||
| awk 'NF && /^[0-9]+$/ { sum += $1 } END { print sum+0 }' \
|
||||
2>/dev/null || echo ""
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then
|
||||
echo "[!] Could not determine size for ${IMAGE} platform=${PLATFORM} — skipping budget check" >&2
|
||||
echo " (imagetools may not support format templates on this buildx version)" >&2
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
# Local-image path: use docker image inspect (host architecture only)
|
||||
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
|
||||
fi
|
||||
|
||||
ACTUAL_MB=$(( ACTUAL_BYTES / 1048576 ))
|
||||
MAX_MB=$(( MAX_BYTES / 1048576 ))
|
||||
LABEL="${IMAGE}${PLATFORM:+ (${PLATFORM})}"
|
||||
|
||||
if (( ACTUAL_BYTES > MAX_BYTES )); then
|
||||
echo "[X] Image size check FAILED: ${IMAGE}" >&2
|
||||
echo "[X] Image size check FAILED: ${LABEL}" >&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 "[OK] Image size check PASSED: ${LABEL}"
|
||||
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)"
|
||||
|
||||
@@ -8,12 +8,17 @@
|
||||
# - Dashboard: http://ccs:3000
|
||||
#
|
||||
# Requires: Docker with compose plugin, internet access to pull curlimages/curl
|
||||
# Usage: bash tests/docker/network-contract.sh
|
||||
# (called from repo root so docker/compose.yaml path resolves)
|
||||
# Usage: bash tests/docker/network-contract.sh [compose-file] [image-ref]
|
||||
# compose-file Path to compose file (default: docker/compose.yaml)
|
||||
# image-ref Override the image used in the compose file (optional).
|
||||
# When set, the compose stack is run with that image instead
|
||||
# of whatever is pinned in the compose file.
|
||||
# Called from repo root so the default path resolves.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
COMPOSE_FILE="docker/compose.yaml"
|
||||
COMPOSE_FILE="${1:-docker/compose.yaml}"
|
||||
IMAGE_OVERRIDE="${2:-}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -26,7 +31,12 @@ err() { printf '[X] %s\n' "$*" >&2; }
|
||||
# Bring stack up; register teardown on any exit
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Bringing CCS stack up: $COMPOSE_FILE"
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
if [[ -n "$IMAGE_OVERRIDE" ]]; then
|
||||
log "Overriding image with: $IMAGE_OVERRIDE"
|
||||
CCS_IMAGE="$IMAGE_OVERRIDE" docker compose -f "$COMPOSE_FILE" up -d
|
||||
else
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
log "Tearing down stack..."
|
||||
@@ -35,7 +45,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wait for healthcheck (max 90s)
|
||||
# Wait for healthcheck (max 90s) — use jq instead of python3 for CI portability
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Waiting for healthcheck to pass (max 90s)..."
|
||||
WAIT_MAX=45 # 45 x 2s = 90s
|
||||
@@ -43,20 +53,10 @@ HEALTHY=0
|
||||
for _i in $(seq 1 "$WAIT_MAX"); do
|
||||
STATUS=$(
|
||||
docker compose -f "$COMPOSE_FILE" ps --format json 2>/dev/null \
|
||||
| python3 -c "
|
||||
import sys, json
|
||||
data = sys.stdin.read().strip()
|
||||
if not data:
|
||||
print('unknown')
|
||||
raise SystemExit(0)
|
||||
rows = json.loads('[' + ','.join(data.splitlines()) + ']')
|
||||
for r in rows:
|
||||
if 'ccs' in r.get('Service', ''):
|
||||
print(r.get('Health', 'unknown'))
|
||||
raise SystemExit(0)
|
||||
print('unknown')
|
||||
" 2>/dev/null || echo "unknown"
|
||||
| jq -r 'if type == "array" then .[] else . end | select(.Service != null and (.Service | contains("ccs"))) | .Health // "unknown"' \
|
||||
2>/dev/null | head -1 || echo "unknown"
|
||||
)
|
||||
STATUS="${STATUS:-unknown}"
|
||||
if [ "$STATUS" = "healthy" ]; then
|
||||
HEALTHY=1
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user