diff --git a/.github/workflows/breaking-change-guard.yml b/.github/workflows/breaking-change-guard.yml index 876bfb1f..ecd5d21c 100644 --- a/.github/workflows/breaking-change-guard.yml +++ b/.github/workflows/breaking-change-guard.yml @@ -71,15 +71,137 @@ jobs: 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|:[^:/]*$||' } - OLD_RAW=$(git show "${BASE}:docker/compose.yaml" \ - | grep -m1 '^\s*image:' | sed 's/.*image:\s*//' | tr -d ' ') - NEW_RAW=$(grep -m1 '^\s*image:' docker/compose.yaml \ - | sed 's/.*image:\s*//' | tr -d ' ') + + 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 @@ -87,19 +209,17 @@ jobs: 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" + # 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=$(git show "${BASE}:docker/compose.yaml" \ - | grep 'container_name:' | tr -d ' ' || echo "") - NEW_CN=$(grep 'container_name:' docker/compose.yaml | tr -d ' ' || echo "") + 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 diff --git a/docker/Dockerfile.integrated b/docker/Dockerfile.integrated index c4a6e3ab..7d1bd03c 100644 --- a/docker/Dockerfile.integrated +++ b/docker/Dockerfile.integrated @@ -1,4 +1,9 @@ -FROM eceasy/cli-proxy-api:latest +# Base image pinned by digest for reproducible builds. +# To refresh: docker pull eceasy/cli-proxy-api:latest && docker inspect --format='{{index .RepoDigests 0}}' eceasy/cli-proxy-api:latest +# Refresh cadence: quarterly OR on security advisory from upstream. +# TODO: pin apk packages once apk-digest pinning workflow exists in CI (apk add nodejs npm are currently unpinned; +# alpine package versions can be pinned but require per-package version discovery and maintenance). +FROM eceasy/cli-proxy-api:latest@sha256:4fa722b9ff83adfbe6e350d433b7dcb754756aa63df95a7a6349767dc13b2cb7 ARG CCS_NPM_VERSION=latest diff --git a/docker/compose.yaml b/docker/compose.yaml index 7f08f9b7..65584042 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -14,6 +14,11 @@ services: ccs: + # image uses Docker Compose env-var-with-default syntax so CI smoke tests can + # override via CCS_IMAGE=ghcr.io/.../ccs: docker compose up -d while + # end users get the documented :latest by default. The Cloudflare Worker that + # serves this file at ccs.kaitran.ca validates that the default part starts + # with the canonical ghcr.io/kaitranntt/ccs prefix. image: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest} restart: unless-stopped ports: @@ -41,7 +46,7 @@ services: const http = require('http'); let pending = 2; let ok = true; const check = (port) => http.get('http://127.0.0.1:'+port+'/', r => { - if (r.statusCode >= 500) ok = false; + if (r.statusCode >= 400) ok = false; if (--pending === 0) process.exit(ok ? 0 : 1); }).on('error', () => { ok = false; if (--pending === 0) process.exit(1); }); check(3000); check(8317); diff --git a/tests/docker/compose-parity.sh b/tests/docker/compose-parity.sh index d94d83a5..a97827c0 100644 --- a/tests/docker/compose-parity.sh +++ b/tests/docker/compose-parity.sh @@ -15,8 +15,8 @@ # set -euo pipefail -CANONICAL="docker/compose.yaml" -INTEGRATED="docker/docker-compose.integrated.yml" +CANONICAL="${COMPOSE_PARITY_CANONICAL:-docker/compose.yaml}" +INTEGRATED="${COMPOSE_PARITY_INTEGRATED:-docker/docker-compose.integrated.yml}" fail=0 @@ -34,12 +34,31 @@ image_name() { # Match lines like: # image: ghcr.io/owner/repo:tag # image: ${CCS_IMAGE:-ghcr.io/owner/repo:tag} - grep -A 50 "^ ${service}:" "$file" \ - | grep -m1 '^\s*image:' \ - | sed 's/.*image:\s*//' \ - | sed 's/\${[^:-]*:-\([^}]*\)}/\1/' \ - | sed 's/:.*//' \ - | tr -d ' ' + # image: registry.local:5000/owner/repo:tag (registry with port — must NOT truncate at first colon) + # + # Pipeline: + # 1. Extract raw image value (strip "image:" prefix and whitespace) + # 2. Strip ${VAR:-default} wrapper if present — must happen AFTER whitespace + # removal so the anchor ^ matches at position 0 + # 3. Strip only the trailing :tag suffix, preserving internal colons + # (e.g. registry:5000/owner/repo keeps its port colon intact) + # + # Mirrors the extract_image_name() logic in .github/workflows/breaking-change-guard.yml. + awk -v service="$service" ' + /^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 && /^[[:space:]]*image:/ { + sub(/^[[:space:]]*image:[[:space:]]*/, "", $0) + print + exit + } + ' "$file" \ + | tr -d ' ' \ + | sed -E "s/^[\"']//; s/[\"']$//" \ + | sed -E 's/^\$\{[A-Za-z_][A-Za-z0-9_]*:-//; s/\}$//' \ + | sed 's|:[^:/]*$||' } # --------------------------------------------------------------------------- @@ -48,7 +67,7 @@ image_name() { exposed_ports() { local file="$1" # Match port mappings: "HOST:CONTAINER" — extract CONTAINER port number - grep -E '^\s+- "[0-9]+:[0-9]+"' "$file" \ + grep -E '^[[:space:]]+- "[0-9]+:[0-9]+"' "$file" \ | sed 's/.*:\([0-9]*\)".*/\1/' \ | sort -n } @@ -59,7 +78,7 @@ exposed_ports() { volume_targets() { local file="$1" # Match volume entries: - name:/container/path - grep -E '^\s+- [a-z_]+:/' "$file" \ + grep -E '^[[:space:]]+- [a-z_]+:/' "$file" \ | sed 's/.*:\(\/[^[:space:]]*\).*/\1/' \ | sort } diff --git a/tests/docker/image-size-logic.test.sh b/tests/docker/image-size-logic.test.sh index 1fdcdd4e..5b5255c1 100755 --- a/tests/docker/image-size-logic.test.sh +++ b/tests/docker/image-size-logic.test.sh @@ -37,7 +37,8 @@ run_test() { # ------------------------------------------------------------------ MOCK_DIR="$(mktemp -d)" -trap 'rm -rf "$MOCK_DIR"' EXIT +COMPOSE_FIXTURE_DIR="" +trap 'rm -rf "$MOCK_DIR" "$COMPOSE_FIXTURE_DIR"' EXIT make_mock_docker() { local size="$1" @@ -201,6 +202,122 @@ run_platform_test "--platform: fail loudly when reported size is 0 (REV5 guard)" make_mock_docker_platform "" run_platform_test "--platform: fail loudly when size output is empty (REV5 guard)" 1 "209715200" +# ------------------------------------------------------------------ +# compose-parity image_name() regression — M5 guard +# +# Verifies the sed pipeline in tests/docker/compose-parity.sh correctly +# strips only the trailing :tag and does NOT truncate at the first colon +# (which would break registry:port/owner/repo references). +# ------------------------------------------------------------------ +echo "" +echo "Running compose-parity image_name() regex tests..." +echo "" + +# Inline the updated image_name() logic from compose-parity.sh so these tests +# run without depending on a compose file on disk. +_image_name_from_raw() { + local raw="$1" + printf '%s' "$raw" \ + | sed -E "s/^[\"']//; s/[\"']$//" \ + | sed -E 's/^\$\{[A-Za-z_][A-Za-z0-9_]*:-//; s/\}$//' \ + | sed 's|:[^:/]*$||' \ + | tr -d ' ' +} + +run_image_name_test() { + local name="$1" input="$2" expected="$3" + local got + got=$(_image_name_from_raw "$input") + if [[ "$got" == "$expected" ]]; then + echo "[OK] ${name}" + (( PASS++ )) || true + else + echo "[X] ${name}: expected='${expected}' got='${got}'" + (( FAIL++ )) || true + fi +} + +# Plain image reference — tag stripped +run_image_name_test \ + "plain image: strip :tag" \ + "ghcr.io/kaitranntt/ccs:latest" \ + "ghcr.io/kaitranntt/ccs" + +# Env-var-with-default syntax — wrapper stripped, then tag stripped +run_image_name_test \ + "env-var default: strip wrapper and :tag" \ + '${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}' \ + "ghcr.io/kaitranntt/ccs" + +# Registry with port — internal colon preserved, only :tag stripped +run_image_name_test \ + "registry:port/owner/repo:tag — preserve internal colon" \ + "registry.local:5000/owner/repo:tag" \ + "registry.local:5000/owner/repo" + +# ------------------------------------------------------------------ +# compose-parity service-scoped extraction regression — reviewer guard +# +# Verifies the real compose-parity script does not read a later sidecar image +# when services.ccs itself lacks an image. +# ------------------------------------------------------------------ +echo "" +echo "Running compose-parity scoped extraction tests..." +echo "" + +COMPOSE_FIXTURE_DIR="$(mktemp -d)" +CANONICAL_FIXTURE="${COMPOSE_FIXTURE_DIR}/compose.yaml" +INTEGRATED_FIXTURE="${COMPOSE_FIXTURE_DIR}/integrated.yaml" + +cat > "$CANONICAL_FIXTURE" <<'YAML' +services: + ccs: + ports: + - "3000:3000" + - "8317:8317" + volumes: + - ccs_home:/root/.ccs + - ccs_logs:/var/log/ccs + networks: + - ccs-net + sidecar: + image: ghcr.io/kaitranntt/ccs:latest +volumes: + ccs_home: + ccs_logs: +networks: + ccs-net: + name: ccs-net +YAML + +cat > "$INTEGRATED_FIXTURE" <<'YAML' +services: + ccs-cliproxy: + image: ccs-cliproxy:latest + ports: + - "3000:3000" + - "8317:8317" + volumes: + - ccs_home:/root/.ccs + - ccs_logs:/var/log/ccs +volumes: + ccs_home: + ccs_logs: +YAML + +actual_exit=0 +COMPOSE_PARITY_CANONICAL="$CANONICAL_FIXTURE" \ + COMPOSE_PARITY_INTEGRATED="$INTEGRATED_FIXTURE" \ + bash "${SCRIPT_DIR}/compose-parity.sh" > /dev/null 2>&1 || actual_exit=$? + +if [[ "$actual_exit" -ne 0 ]]; then + echo "[OK] compose-parity does not bleed into later sidecar image" + (( PASS++ )) || true +else + echo "[X] compose-parity should fail when services.ccs lacks an image" + (( FAIL++ )) || true +fi + # ------------------------------------------------------------------ # Summary # ------------------------------------------------------------------ diff --git a/tests/unit/scripts/github/self-hosted-runner-policy.test.ts b/tests/unit/scripts/github/self-hosted-runner-policy.test.ts index f9e18593..5abcced9 100644 --- a/tests/unit/scripts/github/self-hosted-runner-policy.test.ts +++ b/tests/unit/scripts/github/self-hosted-runner-policy.test.ts @@ -60,6 +60,17 @@ describe('self-hosted runner policy', () => { } }); + test('breaking-change guard scopes compose contract checks to services.ccs', () => { + const workflow = fs.readFileSync(path.join(workflowsDir(), 'breaking-change-guard.yml'), 'utf8'); + + expect(workflow).toContain('OLD_RAW=$(service_field_value "$BASE_COMPOSE" ccs image)'); + expect(workflow).toContain('NEW_RAW=$(service_field_value docker/compose.yaml ccs image)'); + expect(workflow).toContain('has_service_network_effective_name docker/compose.yaml ccs ccs-net'); + expect(workflow).toContain('OLD_CN=$(service_field_value "$BASE_COMPOSE" ccs container_name'); + expect(workflow).not.toContain("grep -m1 '^[[:space:]]*image:'"); + expect(workflow).not.toContain('services.ccs.hostname override'); + }); + test('gates pull-request self-hosted worker deploys to trusted authors', () => { const workflow = fs.readFileSync(path.join(workflowsDir(), 'deploy-ccs-worker.yml'), 'utf8');