diff --git a/.github/workflows/coverage-floor.yml b/.github/workflows/coverage-floor.yml index d29deaa..6c38b6a 100644 --- a/.github/workflows/coverage-floor.yml +++ b/.github/workflows/coverage-floor.yml @@ -112,7 +112,7 @@ on: type: boolean default: false postgres_image: - description: "Docker image for the postgres service. Default 'postgres:16'. A bare Docker Official Image name like that one (no '/') is pulled from Google's Docker Hub mirror as mirror.gcr.io/library/, because Docker Hub rate-limits the anonymous pulls GitHub-hosted runners make. A reference with a '/' is pulled exactly as given, so pass a Docker Hub image outside library/ in its mirror form, e.g. 'mirror.gcr.io/pgvector/pgvector:pg16' when tests need the vector extension." + description: "Docker image for the postgres service. Default 'postgres:16'. A bare Docker Official Image name like that one (no '/') is pulled from Google's Docker Hub mirror as mirror.gcr.io/library/, because Docker Hub rate-limits the anonymous pulls GitHub-hosted runners make. A reference with a '/' is pulled exactly as given, so pass a Docker Hub image outside library/ in its mirror form, e.g. 'mirror.gcr.io/pgvector/pgvector:pg16' when tests need the vector extension. Pulled only when services_postgres is true." required: false type: string default: "postgres:16" @@ -122,7 +122,7 @@ on: type: boolean default: false redis_image: - description: "Docker image for the redis service. Default 'redis:7'. Pulled like postgres_image: a bare name from mirror.gcr.io/library/, a reference with a '/' exactly as given." + description: "Docker image for the redis service. Default 'redis:7'. Pulled like postgres_image: a bare name from mirror.gcr.io/library/, a reference with a '/' exactly as given, and only when services_redis is true." required: false type: string default: "redis:7" @@ -216,6 +216,11 @@ jobs: measure: name: Measure coverage and enforce floor runs-on: ubuntu-latest + # A service starts only when its services_* input asks: GitHub skips a + # service whose image is an empty string, so a caller that never uses a + # service pulls nothing at all. Pinned by + # selftest/test_coverage_floor_services.py. + # # Docker Hub rate-limits anonymous pulls per IP, and GitHub-hosted runners # share egress IPs: on 2026-10-09 dotclaude#479 (run 37991904725) and #485 # failed before their first step on `docker pull postgres:16` -> @@ -229,7 +234,7 @@ jobs: # Pinned by selftest/test_job_images_off_docker_hub.py. services: postgres: - image: ${{ contains(inputs.postgres_image || 'postgres:16', '/') && inputs.postgres_image || format('mirror.gcr.io/library/{0}', inputs.postgres_image || 'postgres:16') }} + image: ${{ inputs.services_postgres && (contains(inputs.postgres_image || 'postgres:16', '/') && inputs.postgres_image || format('mirror.gcr.io/library/{0}', inputs.postgres_image || 'postgres:16')) || '' }} env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -242,7 +247,7 @@ jobs: --health-timeout 5s --health-retries 5 redis: - image: ${{ contains(inputs.redis_image || 'redis:7', '/') && inputs.redis_image || format('mirror.gcr.io/library/{0}', inputs.redis_image || 'redis:7') }} + image: ${{ inputs.services_redis && (contains(inputs.redis_image || 'redis:7', '/') && inputs.redis_image || format('mirror.gcr.io/library/{0}', inputs.redis_image || 'redis:7')) || '' }} ports: - 6379:6379 options: >- diff --git a/selftest/test_coverage_floor_services.py b/selftest/test_coverage_floor_services.py new file mode 100644 index 0000000..4dff778 --- /dev/null +++ b/selftest/test_coverage_floor_services.py @@ -0,0 +1,81 @@ +"""Guard: coverage-floor.yml starts a service container only when asked. + +2026-10-09: both service images were unconditional, so every caller pulled +postgres:16 and redis:7 from Docker Hub on every run, although +services_postgres / services_redis gated only what the tests saw (the +DATABASE_URL / REDIS_URL exports). Docker Hub's unauthenticated pull limit +then failed the job before it measured anything (topcoder1/dotclaude#485's +coverage-floor runs, among others), and many callers require this check. +Only inbox_superpilot opts in; every other caller pulled two images it never +used. + +GitHub skips a service whose image is an empty string, so each image is gated +on its input. Switched on, a service pulls from Google's Docker Hub mirror +(test_job_images_off_docker_hub.py pins where each image comes from). The +image expressions are rendered here the way Actions renders them (`resolve` in +that file), with the inputs a caller gets: the workflow's declared defaults, +the caller's values on top. The expected images are hardcoded. +""" + +import pathlib + +import pytest +import yaml + +from selftest.test_job_images_off_docker_hub import resolve + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "coverage-floor.yml" + +# What each service's default image resolves to once it is switched on. +PG = "mirror.gcr.io/library/postgres:16" +REDIS = "mirror.gcr.io/library/redis:7" + + +def _images(caller): + """Each service's image, as Actions renders it for a caller passing `caller`.""" + workflow = yaml.safe_load(WORKFLOW.read_text()) + declared = workflow.get("on", workflow.get(True))["workflow_call"]["inputs"] + inputs = {name: spec.get("default") for name, spec in declared.items()} + inputs.update(caller) + services = workflow["jobs"]["measure"]["services"] + return { + name: resolve(service["image"], inputs) for name, service in services.items() + } + + +@pytest.mark.parametrize( + "caller, expected", + [ + # A caller that sets neither input (all but inbox_superpilot) pulls nothing. + ({}, {"postgres": "", "redis": ""}), + # An image alone does not start its service. + ( + {"postgres_image": "pgvector/pgvector:pg16", "redis_image": "redis:7"}, + {"postgres": "", "redis": ""}, + ), + ({"services_postgres": True}, {"postgres": PG, "redis": ""}), + ({"services_redis": True}, {"postgres": "", "redis": REDIS}), + # inbox_superpilot's caller (an image with a '/' is pulled as given). + ( + { + "services_postgres": True, + "services_redis": True, + "postgres_image": "pgvector/pgvector:pg16", + }, + {"postgres": "pgvector/pgvector:pg16", "redis": REDIS}, + ), + # An empty image input still falls back to the default image. + ( + { + "services_postgres": True, + "postgres_image": "", + "services_redis": True, + "redis_image": "", + }, + {"postgres": PG, "redis": REDIS}, + ), + ], +) +def test_a_service_starts_only_when_its_input_asks(caller, expected): + assert _images(caller) == expected diff --git a/selftest/test_job_images_off_docker_hub.py b/selftest/test_job_images_off_docker_hub.py index 9af86a4..a95f9d1 100644 --- a/selftest/test_job_images_off_docker_hub.py +++ b/selftest/test_job_images_off_docker_hub.py @@ -16,6 +16,11 @@ namespace such as pgvector/pgvector) is pulled exactly as given: GitHub's expression language cannot split a string to tell the two apart. +Each service also starts only when its services_* input is true: switched off, +its image is '' and nothing is pulled at all (test_coverage_floor_services.py +pins that). So the guard and REQUIRED switch a service on before asking where +its image comes from. + Two layers: 1. The guard (`docker_hub_pulls`) resolves every image a job pulls (each @@ -239,13 +244,24 @@ def _declared_inputs(workflow: dict) -> dict: def _scenarios(template: str, declared: dict): defaults = {name: spec.get("default") for name, spec in declared.items()} + names = sorted(set(re.findall(r"inputs\.([A-Za-z_][\w-]*)", template, re.I))) + # A boolean input an image reads switches its service on or off. A caller + # that passes an image uses that service, so those scenarios switch it on; + # left off, the image is '' and nothing is pulled. + on = { + name: True + for name in names + if (declared.get(name) or {}).get("type") == "boolean" + } yield "inputs empty (this repo's own runs)", {} yield "caller passes nothing (declared defaults)", defaults - for name in sorted(set(re.findall(r"inputs\.([A-Za-z_][\w-]*)", template, re.I))): + if on: + yield f"caller switches on {', '.join(on)}", {**defaults, **on} + for name in names: if (declared.get(name) or {}).get("type", "string") != "string": continue for sample in BARE_SAMPLES: - yield f"caller passes {name}: {sample}", {**defaults, name: sample} + yield f"caller passes {name}: {sample}", {**defaults, **on, name: sample} def docker_hub_pulls(text: str) -> list: @@ -261,11 +277,13 @@ def docker_hub_pulls(text: str) -> list: return found -# The image slots known to exist, the input that feeds each, and what a caller -# value resolves to (None = `inputs` empty). Hardcoded, never read from the +# The image slots known to exist, the input that switches each service on, the +# input that feeds its image, and what a caller value resolves to once the +# service is on (None = no image input). Hardcoded, never read from the # workflow: derived expectations cannot fail when the workflow narrows. REQUIRED = { ("coverage-floor.yml", "measure", "services.postgres"): ( + "services_postgres", "postgres_image", { None: MIRROR + "postgres:16", @@ -279,6 +297,7 @@ def docker_hub_pulls(text: str) -> list: }, ), ("coverage-floor.yml", "measure", "services.redis"): ( + "services_redis", "redis_image", { None: MIRROR + "redis:7", @@ -296,7 +315,7 @@ def _check_required(workflow_name: str, text: str) -> None: (job_id, slot): template for job_id, slot, template in image_slots(yaml.safe_load(text)) } - for (name, job_id, slot), (input_name, cases) in REQUIRED.items(): + for (name, job_id, slot), (switch, input_name, cases) in REQUIRED.items(): if name != workflow_name: continue assert (job_id, slot) in slots, ( @@ -304,7 +323,9 @@ def _check_required(workflow_name: str, text: str) -> None: "images it is known to check" ) for value, want in cases.items(): - inputs = {} if value is None else {input_name: value} + inputs = ( + {switch: True} if value is None else {switch: True, input_name: value} + ) got = resolve(slots[(job_id, slot)], inputs) assert got == want, ( f"{name} jobs.{job_id}.{slot}: {input_name}={value!r} -> {got!r}, want {want!r}"