fix(docker): apply red-team findings — drop :full, rc.1 soak, healthcheck, signing (#1251) (#1262)

* 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:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-05-16 13:33:12 -04:00
1 parent b50c2db3ce
commit 107b5b5db4
16 files changed
+767 -221

No files matched your search

+115
View File
@@ -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"
+14
View File
@@ -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
+125 -103
View File
@@ -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}"
+69
View File
@@ -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"
+16 -42
View File
@@ -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