mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
* feat(docker): publish ccs:latest + ccs:full integrated images (P1)
* feat(docker): parameterize Dockerfile.integrated with ARG FLAVOR=minimal|full
Add FLAVOR build arg that gates the AI CLI install layer (claude-code,
gemini-cli, grok-cli, opencode) so one Dockerfile produces both the
minimal (< 350 MB) and full (< 600 MB) integrated images.
Use BuildKit cache mount for /root/.npm to speed up repeated builds.
Part (P1 — publish integrated images).
* feat(docker): emit startup deprecation warning in legacy ccs-dashboard entrypoint
Prepend a [WARN] line to stderr on every container start so operators
running ghcr.io/kaitranntt/ccs-dashboard:latest are notified to migrate
to ghcr.io/kaitranntt/ccs:latest. Sunset window: 2 releases. See .
* test(docker): add image-size.sh budget assertion + unit test suite
image-size.sh: asserts docker image inspect .Size against a byte budget.
Usage: image-size.sh <image:tag> <max-bytes>
Budgets: minimal=350 MB (367001600), full=600 MB (629145600)
image-size-logic.test.sh: 6 mock-docker unit tests covering pass/fail
boundaries, bad arg count, and non-integer input. All 6 pass locally.
Part (P1).
* ci(docker): extend docker-release.yml to publish ccs:latest and ccs:full
- Add publish-integrated job with matrix flavor: [minimal, full]
- minimal -> ghcr.io/kaitranntt/ccs:<ver> + :latest (when promoted)
- full -> ghcr.io/kaitranntt/ccs:full-<ver> + :full (when promoted)
- Multi-arch: linux/amd64 + linux/arm64 via buildx
- Scoped GHA cache per flavor to avoid cross-contamination
- Add promote_to_latest workflow_dispatch input (default false)
- rc.1 soak: first publish only pushes immutable version tag
- Mutable :latest/:full promoted only on release event OR explicit opt-in
- Add smoke-test job (post-publish) for each flavor
- Pulls the just-published version tag
- Asserts image size via tests/docker/image-size.sh
- Boots container, waits for healthcheck, probes :3000 and :8317
- Keep publish-dashboard job unchanged (legacy 2-release sunset)
- Updated labels to note deprecation status
All jobs run on self-hosted cliproxy runners. Part (P1).
* docs(docker): document new image tags, add ccs-dashboard deprecation notices
docker/README.md:
- Add "Choosing an image" table (ccs:latest / ccs:full / ccs-dashboard deprecated)
- Update Quick Start section to use ccs:latest as primary example
- Add legacy image note with sunset timeline
CHANGELOG.md:
- Add Unreleased > ### Deprecated entry for ccs-dashboard:latest
pointing to migration path and
README.md:
- Add one-line deprecation banner near top routing users to ccs:latest
Part (P1).
* refactor(docker): drop Bun from runtime stage; generate npm lockfile in build stage
- Build stage keeps Bun for fast installs; appends `npm install --package-lock-only`
to generate an ephemeral package-lock.json immediately after `bun run build:all`.
- Runtime stage removes BUN_VERSION ARG/ENV, the bun.sh curl install, and
`bun install --frozen-lockfile --production`. Replaces with:
COPY --from=build /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
- --ignore-scripts rationale: postinstall (scripts/postinstall.js) writes ~/.ccs/
config — not needed in Docker context. bcrypt v6+ is pure-JS, no native compile.
- package-lock.json already in .gitignore (line 33); never committed.
- docker/Dockerfile.integrated unchanged — no Bun present there (alpine + npm).
- Targets: image size reduction >= 300 MB by eliminating ~130 MB Bun binary + installer.
* feat(docker): canonical compose.yaml + smoke-test (P2)
* feat(docker): add canonical compose.yaml for zero-install flow
* ci(docker): smoke-test ccs.kaitran.ca/docker-compose.yaml on release + nightly
* docs(docker): document ccs-net contract for sibling containers
Add "Connect your app to CLIProxy" section to docker/README.md with:
- Public contract table (network=ccs-net, service DNS=ccs, ports 8317/3000)
- Pattern A: same compose file with external network reference
- Pattern B: docker run --network ccs-net
- Troubleshooting subsection (DNS, missing network, conflict, Podman, MTU)
Add one-line link in README.md pointing to the new section.
Add CHANGELOG entry under Unreleased noting the contract as SemVer-major stable.
Add CONTRIBUTING.md note that changing services.ccs or networks.ccs-net requires major bump.
* docs(docker): P3 — hoist two-command quickstart, restructure docker/README, add parity CI
* docs: hoist Docker zero-install quickstart above npm install path
- Create docs/quickstart-snippet.md as canonical source for the
two-command flow (curl + docker compose up -d), wrapped in
<!-- quickstart-snippet-start/end --> markers
- Hoist the snippet into README.md immediately below the deprecation
banner, above all other install paths
- Rename old npm-only "## Quick Start" to "## Install on Host (npm)"
and move it below the Docker quickstart
* docs(docker): restructure README with zero-install first and migration section
- Reorder top-level sections: zero-install (canonical snippet with
markers), choosing an image, power-user ccs docker, prebuilt image,
connect your app to CLIProxy, migration, env vars, troubleshooting
- Add deprecation banner at the top pointing at the migration section
- Add ## Migration from ccs-dashboard:latest section with step-by-step
instructions covering compose down, data preservation, named volume
vs bind-mount path, and compose up with the new image
- Keep P1's Choosing an image table and P5's Connect Your App to
CLIProxy section intact, just repositioned
* test(docs): parity check for quickstart snippet across README files
Assert README.md and docker/README.md both contain the canonical
quickstart block verbatim, anchored by marker comments. Exits non-zero
and prints a diff on any drift.
* ci(docs): wire quickstart-parity test on push and PR
Runs tests/docs/quickstart-parity.sh on self-hosted runner whenever
docs/quickstart-snippet.md, README.md, docker/README.md, or the
test/workflow files themselves change. Fails fast on snippet drift.
* fix(docker): apply red-team findings — drop :full, rc.1 soak, healthcheck, signing
* 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
* fix: address upstream reviewer findings + failing CI checks (loop 1)
* 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 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.
* fix: address reviewer findings round 2 (loop 2)
* fix(docker): parameterize image with CCS_IMAGE env so smoke-test exercises new digest
docker/compose.yaml hardcoded image: ghcr.io/kaitranntt/ccs:latest, causing
network-contract.sh to always use the old published image regardless of what
IMAGE_OVERRIDE was passed. Switching to \${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}
lets CI pass CCS_IMAGE=<digest> so the smoke-test actually exercises the
just-built image. Default end-user behaviour is unchanged.
Resolves REV4 (reviewer loop 2, PR).
* fix(test): image-size.sh fails loudly on manifest inspection failure
The --platform branch previously exited 0 when imagetools inspect returned
empty or zero bytes, silently passing the budget check. A broken image
could reach production undetected if buildx had any inspection issue.
Changed to exit 1 with a clear diagnostic message explaining possible causes
(old buildx, manifest format mismatch, image not yet pushed). No --allow-inspect-failure
escape hatch is provided; CI must fix the root cause.
Resolves REV5 (reviewer loop 2, PR).
* test(docker): cover --platform branch in image-size-logic tests
Prior tests only exercised the local docker image inspect path. The
--platform branch (imagetools inspect) had zero coverage, meaning the
REV5 silent-pass regression would not have been caught by CI.
Added 5 mock-based test cases for the --platform branch:
- pass when platform-scoped compressed size < budget
- fail when platform-scoped compressed size > budget
- fail (exit 1) when imagetools inspect errors (REV5 guard)
- fail (exit 1) when reported size is "0" (REV5 guard)
- fail (exit 1) when size output is empty (REV5 guard)
Mocks follow the existing pattern (override docker in PATH with a temp
wrapper), extended to handle buildx imagetools inspect subcommand.
Resolves REV6 (reviewer loop 2, PR).
* fix(test): compose-parity image_name() handles \${VAR:-default} syntax
After parameterizing compose.yaml's image field with \${CCS_IMAGE:-...},
the image_name() sed pipeline cut at the first colon in the shell variable
syntax (:-) instead of the tag separator, returning '\${CCS_IMAGE' and
failing the kaitranntt/ccs grep.
Added a sed step to unwrap \${VAR:-default} by extracting just the default
value before stripping the tag. Existing plain image: name:tag references
are unaffected.
* fix: tighten parity check + catch service-key rename (loop 3)
* fix(test): tighten compose-parity image-name match to exact expected values (REV7)
Replace loose substring grep (grep -q "ccs") with exact equality checks
against declared EXPECTED_CANONICAL_IMAGE and EXPECTED_INTEGRATED_IMAGE
constants. The integrated compose builds locally as ccs-cliproxy:latest
(not ghcr.io/kaitranntt/ccs), so both expected names are explicitly
documented at the top of the assertion block.
Fixes: a drift in integrated compose to a wrong owner/registry could
slip through the old "grep -q ccs" check; now any name other than the
declared constant is a hard failure.
* feat(ci): breaking-change-guard catches services.ccs rename — public DNS contract (REV8)
Docker's service-name DNS uses the compose service KEY as the hostname.
Sibling containers on ccs-net reach CCS via http://ccs:8317; renaming
services.ccs: to anything else silently breaks that contract even when
image name, network name, and container_name are unchanged.
Add check 4 to the guard:
- Extract top-level service keys from both base and HEAD versions of
docker/compose.yaml using awk (no external YAML parser required).
- Fail if the "ccs" key is absent from HEAD.
- Fail if the sorted set of service keys differs from base.
Wraps inside the existing git cat-file guard so new-file PRs skip it.
* fix(ci): smoke-test failure check + relax service-key guard (REV9, REV10)
REV9 — docker-release.yml smoke-test: add HEALTHY flag to the boot-and-wait
loop. Previously a timeout (status stays 'starting'/'missing' for all 12
iterations) exited the loop silently, letting port probes be the only net.
Now if the loop exits without HEALTHY=1 the step fails immediately with
container logs and state dump. network-contract.sh already has the HEALTHY
pattern — no change needed there.
REV10 — breaking-change-guard.yml: drop the OLD_KEYS != NEW_KEYS comparison.
That check treated ANY change to the service-key set as breaking — including
adding a harmless sidecar. The DNS contract only requires services.ccs to
exist; other services are irrelevant to the 'ccs' hostname on ccs-net. Keep
only the "ccs key must exist" check; remove the unused OLD_KEYS extraction.
* fix(release)!: decouple npm @latest from Docker rc.1 soak (REV11)
Reverts the loop-1 prerelease channel that was publishing npm as
vX.Y.Z-rc.N to the `rc` dist-tag instead of `latest`.
`main` is now a stable semantic-release channel again: every merge
publishes vX.Y.Z immediately to npm @latest. The rc.1 soak window
that guards Docker mutable tags is moved entirely into the Docker
publish workflow:
- `.releaserc.cjs`: remove `prerelease: 'rc'` from productionConfig,
restore `branches: ['main']` (stable). Restore full successComment
and `released` label. Keep loop-1 releaseNotesGenerator additions
(revert section, breaking change comment).
- `docker-release.yml`: every `release: published` event publishes
only the immutable `:<ver>` Docker tag. `promote-mutable-tags` job
now gates exclusively on `workflow_dispatch` with
`promote_to_latest=true` — no longer triggered automatically by
non-prerelease release events.
- `promote-release.yml`: rewritten as a dispatch wrapper that validates
the stable tag exists and is not a prerelease, verifies the immutable
Docker image is in the registry, then dispatches docker-release.yml
with `promote_to_latest=true`. Removes the `gh release edit
--prerelease=false` approach that required the rc soak to be wired
through GitHub release state.
- `docs/release-process.md`: updated to reflect the decoupled model —
npm @latest is immediate; Docker :latest requires manual promote after
soak. Documents the `why` split between npm and Docker soak windows.
* fix(ci): reviewer loop 6 — REV12 compose image parsing, REV13 fork bypass, REV14 :full audit
* fix(ci): breaking-change-guard handles \${VAR:-default} compose image syntax (REV12)
The sed 's/:.*//' pattern truncated at the first colon in the
\${CCS_IMAGE:-ghcr.io/...} expression, yielding "\${CCS_IMAGE" as the
image name instead of the actual registry path. Any change to the default
image namespace was therefore undetectable.
Introduce extract_image_name() that first strips the \${VAR:-default}
wrapper with a sed -E expression, then strips only the trailing :tag
suffix using a pattern that preserves internal colons (e.g. registry:5000/
owner/repo). Applied to both OLD_RAW and NEW_RAW extraction paths.
* fix(ci): breaking-change-guard runs on ubuntu-latest to cover forked PRs (REV13)
The trusted-author gate (COLLABORATOR|MEMBER|OWNER) caused forked-PR
contributors to bypass the breaking-change check entirely. A forked
contributor could rename services.ccs or change the image namespace
without a feat!/fix! marker and the guard would never run.
This workflow is a documented exception to the self-hosted-first policy:
it performs ONLY pure YAML diff parsing (git show / awk / sed). No build,
install, or arbitrary PR-branch scripts are executed. The checkout uses
persist-credentials: false. There is no untrusted code execution, so
ubuntu-latest is safe and necessary for universal fork coverage.
Update self-hosted-runner-policy.test.ts to:
- Introduce GITHUB_HOSTED_RUNNER_EXCEPTIONS registry with required
justification comments for each entry
- Skip exception workflows in the "keeps active workflows on local runners"
and "gates pull-request workflows" assertions
- Add a new "documented exceptions use github-hosted runners" test that
verifies each exception entry actually uses a GitHub-hosted runner
(prevents stale entries accumulating without cleanup)
* docs(ci): clarify rc.1 soak and :full removal as intentional design
Reviewer kept flagging :full omission and stale :latest as bugs. Both are intentional per Q3 maintainer decision and the rc.1 soak design (loop-5). Strengthen inline comments to make this unmistakable to future readers.
* fix: address final-review criticals — healthcheck/digest/contract guards (loop 8)
* fix(docker): healthcheck fails on 4xx not only 5xx (H1)
Change threshold from >= 500 to >= 400 so a misrouted 404 on / is
treated as unhealthy. A container returning 4xx on the root path is
not serving traffic correctly and must not be reported healthy.
* chore(docker): pin eceasy/cli-proxy-api base image by digest (H2)
FROM eceasy/cli-proxy-api:latest@sha256:4fa722b... locks the base image
to a specific manifest-list digest for reproducible builds. The human-
readable :latest tag is preserved for readability. Refresh cadence:
quarterly or on security advisory from upstream. apk packages (nodejs,
npm) remain unpinned — noted as a known limitation until a CI workflow
for apk-digest pinning exists.
* fix(ci): breaking-change-guard catches network rename and hostname override (M1/M2)
Replace the old diff-based ccs-net check (which missed the case where
both old and new lack the name, producing identical empty strings) with
a positive assertion: the canonical compose MUST contain 'name: ccs-net'.
This catches renames, removals, and whitespace variations.
Also add a hostname: override guard (M2): setting services.ccs.hostname
changes the Docker DNS name on ccs-net even when the service key stays
"ccs", silently breaking http://ccs:8317 for sibling containers.
* fix(test): compose-parity image-name regex preserves registry:port (M5)
The old sed 's/:.*//' truncated at the first colon, incorrectly stripping
registry:port prefixes (e.g. registry.local:5000/owner/repo:tag became
registry.local). Replace with sed 's|:[^:/]*$||' which strips only the
trailing :tag suffix, preserving internal colons.
Move tr -d ' ' before the sed -E wrapper-strip so the '^' anchor matches
correctly after whitespace removal. Mirrors extract_image_name() in
breaking-change-guard.yml.
Add 3 image_name() regression tests to image-size-logic.test.sh covering
plain tag, env-var-with-default syntax, and registry:port/owner/repo:tag.
* ci(pr-agent): disable ticket analysis to avoid sub-issue crash
PR-Agent's fetch_sub_issues crashes when the PR body references PR numbers as shortcodes (etc) — GraphQL repository.issue(number) returns null for PR numbers and pr-agent dereferences without a guard (github_provider.py:1048). Disable ticket_analysis_review until upstream fixes the None guard. Other reviewer sections (security, score, tests, etc.) remain enabled.
* ci(pr-agent): re-enable ticket analysis after history+body rewrite
PR shortcodes removed from all commit messages and PR body, so fetch_sub_issues no longer hits the upstream null-deref bug. Ticket compliance review is re-enabled in full.
285 lines
12 KiB
YAML
285 lines
12 KiB
YAML
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)
|
||
# services keys (Docker DNS uses the service key; renaming "ccs:" changes http://ccs:8317)
|
||
#
|
||
# 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
|
||
# Exception to self-hosted-first policy (documented in CLAUDE.md "Self-Hosted Runner Policy"):
|
||
# This workflow MUST cover ALL PRs including forks — a forked contributor can rename
|
||
# services.ccs or change the image namespace without a breaking-change marker, which
|
||
# silently breaks sibling-container setups for every user. Gating on trusted-author
|
||
# association would let forked PRs bypass the check entirely.
|
||
#
|
||
# Safety justification: this workflow does ONLY pure YAML diff parsing (git show / git diff
|
||
# + shell + awk). It checks out the code with persist-credentials: false and runs no build,
|
||
# install, or arbitrary scripts from the PR branch. There is no untrusted code execution,
|
||
# so running on a GitHub-hosted runner is safe and required for universal coverage.
|
||
runs-on: ubuntu-latest
|
||
|
||
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 }}"
|
||
|
||
# Guard: if docker/compose.yaml does not exist in the base branch, this
|
||
# is a new file — no contract existed before, so no regression is possible.
|
||
if ! git cat-file -e "${BASE}:docker/compose.yaml" 2>/dev/null; then
|
||
echo "[i] docker/compose.yaml is new in this PR (not present on ${BASE}) — skipping contract check"
|
||
echo "breaking=0" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
|
||
BREAKING=0
|
||
|
||
# 1. Image name change (repo path, not tag — tags change every release)
|
||
#
|
||
# compose.yaml uses ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}. A naive
|
||
# sed 's/:.*//' truncates at the FIRST colon, yielding "${CCS_IMAGE" instead
|
||
# of the actual image name. We strip the ${VAR:-default} wrapper first, then
|
||
# strip only the trailing :tag suffix (preserving internal colons such as
|
||
# those in registry:port/owner/repo).
|
||
extract_image_name() {
|
||
# $1 = raw image line content (everything after "image: ")
|
||
local raw="$1"
|
||
raw=$(printf '%s' "$raw" | sed -E "s/^[\"']//; s/[\"']$//")
|
||
# Strip ${VAR:-default} wrapper if present
|
||
raw=$(printf '%s' "$raw" | sed -E 's/^\$\{[A-Za-z_][A-Za-z0-9_]*:-//; s/\}$//')
|
||
# Strip only the trailing :tag — preserve internal colons (e.g. registry:5000/owner/repo)
|
||
printf '%s' "$raw" | sed 's|:[^:/]*$||'
|
||
}
|
||
|
||
service_field_value() {
|
||
local file="$1" service="$2" field="$3"
|
||
awk -v service="$service" -v field="$field" '
|
||
function trim(s) {
|
||
sub(/^[[:space:]]+/, "", s)
|
||
sub(/[[:space:]]+$/, "", s)
|
||
return s
|
||
}
|
||
/^services:[[:space:]]*$/ { in_services = 1; next }
|
||
in_services && /^[^[:space:]]/ { in_services = 0; in_service = 0 }
|
||
in_services && $0 ~ "^ " service ":[[:space:]]*$" { in_service = 1; next }
|
||
in_service && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { in_service = 0 }
|
||
in_service {
|
||
prefix = "^ " field ":[[:space:]]*"
|
||
if ($0 ~ prefix) {
|
||
line = $0
|
||
sub(prefix, "", line)
|
||
print trim(line)
|
||
exit
|
||
}
|
||
}
|
||
' "$file"
|
||
}
|
||
|
||
service_network_refs() {
|
||
local file="$1" service="$2"
|
||
awk -v service="$service" '
|
||
function trim(s) {
|
||
sub(/^[[:space:]]+/, "", s)
|
||
sub(/[[:space:]]+$/, "", s)
|
||
return s
|
||
}
|
||
function emit_inline(value, parts, count, i) {
|
||
gsub(/[\[\]]/, "", value)
|
||
count = split(value, parts, ",")
|
||
for (i = 1; i <= count; i++) {
|
||
if (trim(parts[i]) != "") print trim(parts[i])
|
||
}
|
||
}
|
||
/^services:[[:space:]]*$/ { in_services = 1; next }
|
||
in_services && /^[^[:space:]]/ { in_services = 0; in_service = 0; in_networks = 0 }
|
||
in_services && $0 ~ "^ " service ":[[:space:]]*$" { in_service = 1; in_networks = 0; next }
|
||
in_service && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { in_service = 0; in_networks = 0 }
|
||
in_service {
|
||
if ($0 ~ /^ networks:[[:space:]]*\[/) {
|
||
line = $0
|
||
sub(/^ networks:[[:space:]]*/, "", line)
|
||
emit_inline(line)
|
||
next
|
||
}
|
||
if ($0 ~ /^ networks:[[:space:]]*$/) {
|
||
in_networks = 1
|
||
next
|
||
}
|
||
if ($0 ~ /^ networks:[[:space:]]+/) {
|
||
line = $0
|
||
sub(/^ networks:[[:space:]]+/, "", line)
|
||
print trim(line)
|
||
next
|
||
}
|
||
if (in_networks && $0 ~ /^ [A-Za-z0-9_-]+:/) {
|
||
in_networks = 0
|
||
}
|
||
if (in_networks && $0 ~ /^ -[[:space:]]*/) {
|
||
line = $0
|
||
sub(/^ -[[:space:]]*/, "", line)
|
||
print trim(line)
|
||
next
|
||
}
|
||
if (in_networks && $0 ~ /^ [A-Za-z0-9_-]+:/) {
|
||
line = $0
|
||
sub(/^ /, "", line)
|
||
sub(/:.*/, "", line)
|
||
print trim(line)
|
||
next
|
||
}
|
||
}
|
||
' "$file"
|
||
}
|
||
|
||
network_effective_name() {
|
||
local file="$1" ref="$2"
|
||
awk -v ref="$ref" '
|
||
function trim(s) {
|
||
sub(/^[[:space:]]+/, "", s)
|
||
sub(/[[:space:]]+$/, "", s)
|
||
return s
|
||
}
|
||
/^networks:[[:space:]]*$/ { in_networks = 1; next }
|
||
in_networks && /^[^[:space:]]/ { in_networks = 0; in_target = 0 }
|
||
in_networks && $0 ~ "^ " ref ":[[:space:]]*$" {
|
||
in_target = 1
|
||
found = 1
|
||
effective = ref
|
||
next
|
||
}
|
||
in_target && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { in_target = 0 }
|
||
in_target && /^ name:[[:space:]]*/ {
|
||
line = $0
|
||
sub(/^ name:[[:space:]]*/, "", line)
|
||
effective = trim(line)
|
||
}
|
||
END { if (found) print effective }
|
||
' "$file"
|
||
}
|
||
|
||
has_service_network_effective_name() {
|
||
local file="$1" service="$2" expected="$3" ref effective
|
||
while IFS= read -r ref; do
|
||
[[ -z "$ref" ]] && continue
|
||
effective=$(network_effective_name "$file" "$ref")
|
||
if [[ "$effective" == "$expected" ]]; then
|
||
return 0
|
||
fi
|
||
done < <(service_network_refs "$file" "$service")
|
||
return 1
|
||
}
|
||
|
||
BASE_COMPOSE="$(mktemp)"
|
||
trap 'rm -f "$BASE_COMPOSE"' EXIT
|
||
git show "${BASE}:docker/compose.yaml" > "$BASE_COMPOSE"
|
||
|
||
OLD_RAW=$(service_field_value "$BASE_COMPOSE" ccs image)
|
||
NEW_RAW=$(service_field_value docker/compose.yaml ccs image)
|
||
OLD_IMAGE=$(extract_image_name "$OLD_RAW")
|
||
NEW_IMAGE=$(extract_image_name "$NEW_RAW")
|
||
if [[ "${OLD_IMAGE}" != "${NEW_IMAGE}" ]]; then
|
||
echo "[!] BREAKING: image name changed: '${OLD_IMAGE}' -> '${NEW_IMAGE}'"
|
||
BREAKING=1
|
||
fi
|
||
|
||
# 2. ccs-net network attachment — positive assertion: services.ccs MUST
|
||
# attach to a network whose effective Compose name is ccs-net. Merely
|
||
# keeping an unused top-level ccs-net definition is not enough.
|
||
if ! has_service_network_effective_name docker/compose.yaml ccs ccs-net; then
|
||
echo "[!] BREAKING: services.ccs is not attached to effective network name ccs-net"
|
||
BREAKING=1
|
||
fi
|
||
|
||
# 3. container_name change (if present in either version)
|
||
OLD_CN=$(service_field_value "$BASE_COMPOSE" ccs container_name | tr -d ' ')
|
||
NEW_CN=$(service_field_value docker/compose.yaml ccs container_name | tr -d ' ')
|
||
if [[ "${OLD_CN}" != "${NEW_CN}" ]]; then
|
||
echo "[!] BREAKING: container_name changed: '${OLD_CN}' -> '${NEW_CN}'"
|
||
BREAKING=1
|
||
fi
|
||
|
||
# 4. Service key contract — Docker DNS on ccs-net uses the compose service
|
||
# KEY as the hostname (e.g. http://ccs:8317). Renaming or removing
|
||
# "ccs:" breaks every sibling container in the wild. Adding new service
|
||
# keys (e.g. a sidecar) does NOT affect the "ccs" DNS contract and is
|
||
# therefore allowed without a breaking-change marker.
|
||
#
|
||
# Extraction logic: find lines that look like top-level service keys
|
||
# (two-space indent + identifier + colon, NOT sub-keys like "image:").
|
||
# This matches YAML service keys without requiring an external YAML parser.
|
||
NEW_KEYS=$(awk '/^services:/{s=1;next} s && /^ [a-zA-Z0-9_-]+:/{print $1} /^[^ ]/{s=0}' \
|
||
docker/compose.yaml \
|
||
| tr -d ':' | sort | tr '\n' ' ' | sed 's/ $//')
|
||
if ! echo " ${NEW_KEYS} " | grep -q " ccs "; then
|
||
echo "[!] BREAKING: services.ccs key missing from docker/compose.yaml — sibling containers on ccs-net rely on DNS hostname 'ccs'"
|
||
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"
|