diff --git a/.github/workflows/breaking-change-guard.yml b/.github/workflows/breaking-change-guard.yml new file mode 100644 index 00000000..71420bda --- /dev/null +++ b/.github/workflows/breaking-change-guard.yml @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 598e2db3..3ba79cce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 98f5c328..056e0bb0 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -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 : 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< 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, :, : 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}" diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml new file mode 100644 index 00000000..bc2798a0 --- /dev/null +++ b/.github/workflows/promote-release.yml @@ -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" diff --git a/.github/workflows/smoke-test-compose-url.yml b/.github/workflows/smoke-test-compose-url.yml index 058167e0..35c232a3 100644 --- a/.github/workflows/smoke-test-compose-url.yml +++ b/.github/workflows/smoke-test-compose-url.yml @@ -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 diff --git a/.releaserc.cjs b/.releaserc.cjs index af593fa8..8b39669a 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -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'], }, ], [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c5f617..17ba3f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index cd4bce29..ab892b40 100644 --- a/README.md +++ b/README.md @@ -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` ## Install on Host (npm) diff --git a/docker/Dockerfile.integrated b/docker/Dockerfile.integrated index 41db1cdc..f40be85b 100644 --- a/docker/Dockerfile.integrated +++ b/docker/Dockerfile.integrated @@ -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 diff --git a/docker/README.md b/docker/README.md index 97895ab3..0308f347 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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` --- @@ -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:..`, `ccs:.`, `ccs:`) for reproducible deployments. The `:full` variants carry the `full-` prefix: `ccs:full-`, `ccs:full-`, etc. +`ccs:latest` also publishes pinned version tags (`ccs:..`, `ccs:.`, `ccs:`) 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:` (minimal) and `ghcr.io/kaitranntt/ccs:full-` (full). +Release-tag images are published as `ghcr.io/kaitranntt/ccs:` 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: +``` + +**Inspect the SBOM:** + +```bash +cosign download sbom ghcr.io/kaitranntt/ccs: +``` diff --git a/docker/compose.yaml b/docker/compose.yaml index 70cb5239..4f653d16 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -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 diff --git a/docs/quickstart-snippet.md b/docs/quickstart-snippet.md index 9400dce0..d571a79c 100644 --- a/docs/quickstart-snippet.md +++ b/docs/quickstart-snippet.md @@ -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` diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 00000000..072bd1b7 --- /dev/null +++ b/docs/release-process.md @@ -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`, `:`, `:`) 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`, `:`, `:` 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` | `:-rc.N` (immutable only) | +| `main` (after promote) | stable | `@latest` | `:latest`, `:`, `:`, `:` | +| `dev` | `dev` prerelease | `@dev` | not published | diff --git a/tests/docker/compose-parity.sh b/tests/docker/compose-parity.sh new file mode 100644 index 00000000..6da741d4 --- /dev/null +++ b/tests/docker/compose-parity.sh @@ -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" diff --git a/tests/docker/image-size.sh b/tests/docker/image-size.sh index be0c0b4c..c82ed09e 100755 --- a/tests/docker/image-size.sh +++ b/tests/docker/image-size.sh @@ -1,21 +1,46 @@ #!/usr/bin/env bash # Asserts that a Docker image does not exceed a given byte budget. # -# Usage: image-size.sh +# Usage: image-size.sh [--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 " >&2 +if [[ $# -lt 2 ]]; then + echo "[X] Usage: $0 [--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)" diff --git a/tests/docker/network-contract.sh b/tests/docker/network-contract.sh index 2b84cfcc..6e5aa4b1 100755 --- a/tests/docker/network-contract.sh +++ b/tests/docker/network-contract.sh @@ -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