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<> "$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 : 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<> "$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 : 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)..." HEALTHY=0 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" HEALTHY=1 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 if [[ "${HEALTHY}" -ne 1 ]]; then echo "[X] Container did not become healthy within 60s (last status: ${STATUS})" >&2 docker logs "${CONTAINER_NAME}" --tail 100 >&2 docker inspect "${CONTAINER_NAME}" --format='{{json .State}}' >&2 || true exit 1 fi - name: Run network-contract test run: | # network-contract.sh signature: [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, :, : 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}"