Files
ccs/.github/workflows/docker-release.yml
T
Kai (Tam Nhu) TranandGitHub 78004746be fix: address upstream reviewer findings + failing CI checks (#1261 loop 1) (#1271)
* fix(ci): breaking-change-guard skips missing base files (CI-1)

Guard each git-show call with git cat-file -e existence check before
attempting to read docker/compose.yaml from the base branch. When the
file doesn't exist on the base (new file in this PR), the script would
crash with "fatal: path exists on disk but not in origin/dev". New
files can't cause a contract regression, so we exit early with
breaking=0.

* style: prettier reformat unrelated drift (CI-2)

Two files had minor formatting drift from earlier umbrella PRs:
- src/cliproxy/quota/quota-manager.ts
- src/management/checks/image-analysis-check.ts

No logic changes — formatter-only pass to unblock CI format:check.

* fix(test): update trusted-author gate count to 4 in ci-workflow test (CI-3)

PR #1260 added a compose-parity job to ci.yml. That job runs on
self-hosted runners using PR-provided checkout, so it legitimately
requires the trusted-author guard (same as validate, build, test).

The test expected 3 occurrences; the correct count is now 4:
  validate (matrix), build, test, compose-parity.

* fix(ci): smoke-test passes compose path + image ref to network-contract (REV-1)

network-contract.sh signature is: <compose-file> [image-ref]
The smoke-test job was calling it as:
  bash tests/docker/network-contract.sh "${{ steps.image.outputs.ref }}"
which placed the image ref in the compose-file position ($1), causing
the script to try docker compose -f <image-ref> which fails.

Corrected to:
  bash tests/docker/network-contract.sh docker/compose.yaml "${{ steps.image.outputs.ref }}"

Also removes publish-dashboard from smoke-test.needs (REV-2): when
publish-dashboard is SKIPPED on prerelease events, GitHub Actions
propagates the skip to downstream jobs, so smoke-test and
promote-mutable-tags were silently skipped on every rc.N publish.
smoke-test only verifies the integrated image; it has no dependency
on the legacy dashboard image job.

* docs(docker): annotate /root/.ccs path in compose volume (REV-3 clarification)

The reviewer raised a concern that the compose volume mounts /root/.ccs
but the entrypoint might default to /home/node/.ccs. This is a false
positive: the integrated image uses entrypoint-integrated.sh (not
entrypoint.sh), which runs under supervisord with user=root and
explicitly mkdir -p /root/.ccs. HOME is /root inside the container.
The volume mount at /root/.ccs is correct.

Added an inline comment documenting the reasoning so future reviewers
do not confuse entrypoint.sh (legacy dashboard image) with
entrypoint-integrated.sh (integrated image).

* fix(ci): gate docs-parity pull_request job to trusted authors

docs-parity.yml runs on a self-hosted runner and checks out PR code.
The self-hosted-runner-policy test requires any such workflow to include
the trusted-author guard. The workflow was missing the guard, causing
bun test:fast to fail with 1 failure.

Allow push events (no author check needed — push is to own branch)
and trusted-contributor PRs only.
2026-05-16 13:57:50 -04:00

438 lines
17 KiB
YAML

name: Publish Docker Image
on:
release:
types:
- published
workflow_dispatch:
inputs:
tag:
description: Stable tag to publish manually, for example v7.55.0
required: true
type: string
promote_to_latest:
description: >
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 || github.run_id }}
cancel-in-progress: false
jobs:
# ---------------------------------------------------------------------------
# Job 1: Legacy ccs-dashboard image (2-release sunset window)
# ---------------------------------------------------------------------------
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]
permissions:
contents: read
packages: write
steps:
- name: Resolve target tag
id: target
env:
MANUAL_TAG: ${{ inputs.tag }}
RELEASE_EVENT_TAG: ${{ github.event.release.tag_name }}
run: |
if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then
TARGET_TAG="${RELEASE_EVENT_TAG}"
else
TARGET_TAG="${MANUAL_TAG}"
fi
echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT"
- name: Validate stable semver tag
id: tag
run: |
if [[ "${{ steps.target.outputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "Skipping non-stable semver tag ${{ steps.target.outputs.tag }}"
- name: Checkout release tag
if: steps.tag.outputs.publish == 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.tag }}
persist-credentials: false
- name: Set up QEMU
if: steps.tag.outputs.publish == 'true'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: steps.tag.outputs.publish == 'true'
uses: docker/setup-buildx-action@v3
- name: Derive image metadata
if: steps.tag.outputs.publish == 'true'
id: meta
run: |
VERSION="${{ steps.target.outputs.tag }}"
VERSION="${VERSION#v}"
MINOR="${VERSION%.*}"
MAJOR="${VERSION%%.*}"
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
IMAGE="ghcr.io/${OWNER_LOWER}/ccs-dashboard"
REVISION=$(git rev-parse HEAD)
TAGS="${IMAGE}:${VERSION}"
if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then
TAGS="${TAGS}
${IMAGE}:${MINOR}
${IMAGE}:${MAJOR}
${IMAGE}:latest"
fi
{
echo "version=${VERSION}"
echo "minor=${MINOR}"
echo "major=${MAJOR}"
echo "image=${IMAGE}"
echo "revision=${REVISION}"
echo "tags<<EOF"
echo "${TAGS}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
if: steps.tag.outputs.publish == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push legacy dashboard image
if: steps.tag.outputs.publish == 'true'
uses: docker/build-push-action@v6
with:
context: .
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
org.opencontainers.image.description=CCS Dashboard container image (deprecated - migrate to ghcr.io/kaitranntt/ccs:latest)
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
annotations: |
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
index:org.opencontainers.image.description=CCS Dashboard container image (deprecated - migrate to ghcr.io/kaitranntt/ccs:latest)
cache-from: type=gha,scope=dashboard
cache-to: type=gha,mode=max,scope=dashboard
# ---------------------------------------------------------------------------
# Job 2: Integrated image — 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
# 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
outputs:
version: ${{ steps.meta.outputs.version }}
image_ref: ${{ steps.meta.outputs.image_ref }}
publish: ${{ steps.tag.outputs.publish }}
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 semver tag (stable or rc)
id: tag
run: |
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 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'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: steps.tag.outputs.publish == 'true'
uses: docker/setup-buildx-action@v3
- name: Derive image metadata
if: steps.tag.outputs.publish == 'true'
id: meta
run: |
VERSION="${{ steps.target.outputs.tag }}"
VERSION="${VERSION#v}"
MINOR="${VERSION%.*}"
MAJOR="${VERSION%%.*}"
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
IMAGE="ghcr.io/${OWNER_LOWER}/ccs"
REVISION=$(git rev-parse HEAD)
# 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}"
echo "minor=${MINOR}"
echo "major=${MAJOR}"
echo "image=${IMAGE}"
echo "revision=${REVISION}"
echo "image_ref=${IMAGE_REF}"
echo "tags<<EOF"
echo "${TAGS}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
if: steps.tag.outputs.publish == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push integrated image
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: |
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 — 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 — 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: 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 integrated image
# Depends only on publish-integrated, NOT publish-dashboard.
# publish-dashboard is skipped on prerelease (rc.N) events — if it were
# listed here, GitHub Actions would also skip smoke-test and
# promote-mutable-tags on every rc publish, breaking the rc soak flow.
needs: [publish-integrated]
# 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]
steps:
- name: Checkout release tag (for test scripts)
uses: actions/checkout@v4
with:
ref: ${{ needs.publish-integrated.outputs.version != '' && format('v{0}', needs.publish-integrated.outputs.version) || github.ref }}
persist-credentials: false
- name: Derive image reference
id: image
run: |
echo "ref=${{ needs.publish-integrated.outputs.image_ref }}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull image
run: docker pull "${{ steps.image.outputs.ref }}"
- name: Assert image size budget (amd64)
run: |
chmod +x tests/docker/image-size.sh
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
id: boot
run: |
CONTAINER_NAME="ccs-smoke-${{ github.run_id }}"
echo "container=${CONTAINER_NAME}" >> "$GITHUB_OUTPUT"
docker run -d \
--name "${CONTAINER_NAME}" \
--rm \
-p 13000:3000 \
-p 18317:8317 \
"${{ steps.image.outputs.ref }}"
echo "[i] Waiting for container healthcheck (up to 60s)..."
for i in $(seq 1 12); do
STATUS=$(docker inspect "${CONTAINER_NAME}" --format='{{.State.Health.Status}}' 2>/dev/null || echo "missing")
if [[ "${STATUS}" == "healthy" ]]; then
echo "[OK] Container is healthy after $((i * 5))s"
break
fi
if [[ "${STATUS}" == "unhealthy" ]]; then
echo "[X] Container marked unhealthy"
docker logs "${CONTAINER_NAME}" --tail 50 >&2
exit 1
fi
echo " [${i}/12] status=${STATUS}, waiting 5s..."
sleep 5
done
- name: Run network-contract test
run: |
# network-contract.sh signature: <compose-file> [image-ref]
# Pass compose.yaml as $1 and the pinned image ref as $2 so the
# smoke test exercises the canonical compose file with the exact
# image that was just published, not whatever tag is in the file.
bash tests/docker/network-contract.sh docker/compose.yaml "${{ steps.image.outputs.ref }}"
- name: Probe dashboard port 3000
run: |
HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 http://127.0.0.1:13000/ || echo "000")
if [[ "${HTTP_CODE}" == "000" ]]; then
echo "[X] Could not reach dashboard on :3000 (connection failed)"
exit 1
fi
echo "[OK] Dashboard port 3000 responded with HTTP ${HTTP_CODE}"
- name: Probe CLIProxy port 8317
run: |
HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 http://127.0.0.1:18317/ || echo "000")
if [[ "${HTTP_CODE}" == "000" ]]; then
echo "[X] Could not reach CLIProxy on :8317 (connection failed)"
exit 1
fi
echo "[OK] CLIProxy port 8317 responded with HTTP ${HTTP_CODE}"
- name: Stop smoke test container
if: always()
run: |
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}"