From e890d27bbd191ac21cfafbe912515ed470ed357a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 17:34:07 +0000 Subject: [PATCH] ci: build and push only the Docker images that changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously every push to main rebuilt and pushed all 7 images to Docker Hub (build ran via workflow_run after CI), so even a docs-only change triggered a full 7-image rebuild. Make the pipeline modular/granular. - Add .github/build-matrix.json: canonical component registry (service, dockerfile, tag_prefix, paths) — single source of truth for the workflows and the publish-matrix test. - Add scripts/select_build_matrix.py: map changed files to affected images using plain `git diff` + the registry (no third-party changed-files action, avoiding the tj-actions CVE-2025-30066 risk class). - Add .github/workflows/_build-images.yml: reusable workflow that builds and pushes one image; callers drive it with a per-component matrix. - ci.yml: detect changes in the push context (reliable github.event.before) and build only the affected images via the reusable workflow, still gated on the test + security jobs. - build-and-push.yml: drop workflow_run; now handles v* tags (full image set) and manual workflow_dispatch (all, or a single chosen image). - Update tests (registry coverage, node24 pins now on the reusable workflow, full selection-logic coverage) and docs. Docs-only, core, and test changes now build no image; a change under a single component rebuilds only that image. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B2rvA2p3F2H9HRuiE74F3j --- .github/build-matrix.json | 59 +++++++++ .github/workflows/_build-images.yml | 113 +++++++++++++++++ .github/workflows/build-and-push.yml | 176 +++++++++------------------ .github/workflows/ci.yml | 63 ++++++++++ CLAUDE.md | 9 +- README.md | 3 +- scripts/select_build_matrix.py | 97 +++++++++++++++ tests/test_docker_publish_matrix.py | 117 +++++++++++++++++- 8 files changed, 506 insertions(+), 131 deletions(-) create mode 100644 .github/build-matrix.json create mode 100644 .github/workflows/_build-images.yml create mode 100644 scripts/select_build_matrix.py diff --git a/.github/build-matrix.json b/.github/build-matrix.json new file mode 100644 index 0000000..974542d --- /dev/null +++ b/.github/build-matrix.json @@ -0,0 +1,59 @@ +{ + "_comment": "Canonical registry of publishable Docker images. Single source of truth for the GitHub Actions workflows (detect-changes/select + _build-images.yml) and tests/test_docker_publish_matrix.py. 'service' is the Docker Hub image tag pushed as crhacky/blhackbox:. 'paths' globs (an exact path, or a 'prefix/**' recursive prefix) decide which image a changed file rebuilds. 'scout' must stay a JSON boolean so the reusable workflow's `if: inputs.scout` evaluates correctly. NOTE: compose service 'hexstrike-bridge-mcp' uses image tag 'hexstrike-mcp'; 'hexstrike-ai' clones its upstream at build time and copies no repo files, so its only trigger is its Dockerfile.", + "components": [ + { + "service": "kali-mcp", + "dockerfile": "docker/kali-mcp.Dockerfile", + "tag_prefix": "kali-mcp-", + "scout": false, + "paths": ["kali-mcp/**", "docker/kali-mcp.Dockerfile"] + }, + { + "service": "wire-mcp", + "dockerfile": "docker/wire-mcp.Dockerfile", + "tag_prefix": "wire-mcp-", + "scout": false, + "paths": ["wire-mcp/**", "docker/wire-mcp.Dockerfile"] + }, + { + "service": "screenshot-mcp", + "dockerfile": "docker/screenshot-mcp.Dockerfile", + "tag_prefix": "screenshot-mcp-", + "scout": false, + "paths": ["screenshot-mcp/**", "docker/screenshot-mcp.Dockerfile"] + }, + { + "service": "hexstrike-ai", + "dockerfile": "docker/hexstrike-ai.Dockerfile", + "tag_prefix": "hexstrike-ai-", + "scout": false, + "paths": ["docker/hexstrike-ai.Dockerfile"] + }, + { + "service": "hexstrike-mcp", + "dockerfile": "docker/hexstrike-mcp.Dockerfile", + "tag_prefix": "hexstrike-mcp-", + "scout": false, + "paths": ["hexstrike-mcp/**", "docker/hexstrike-mcp.Dockerfile"] + }, + { + "service": "boaz-mcp", + "dockerfile": "docker/boaz-mcp.Dockerfile", + "tag_prefix": "boaz-mcp-", + "scout": false, + "paths": ["boaz-mcp/**", "docker/boaz-mcp.Dockerfile"] + }, + { + "service": "claude-code", + "dockerfile": "docker/claude-code.Dockerfile", + "tag_prefix": "claude-code-", + "scout": false, + "paths": [ + "docker/claude-code.Dockerfile", + "docker/claude-code-entrypoint.sh", + "CLAUDE.md", + ".claude/skills/**" + ] + } + ] +} diff --git a/.github/workflows/_build-images.yml b/.github/workflows/_build-images.yml new file mode 100644 index 0000000..2fedb6d --- /dev/null +++ b/.github/workflows/_build-images.yml @@ -0,0 +1,113 @@ +name: Reusable - Build and push one image + +# Reusable workflow: builds a single Docker image and pushes it to Docker Hub. +# Called once per selected component (caller-side matrix) from: +# - ci.yml (per-component builds on push to main/master) +# - build-and-push.yml (release tags + manual dispatch) +# The caller passes `secrets: inherit` so DOCKERHUB_TOKEN resolves here. + +on: + workflow_call: + inputs: + service: + description: "Image/service name — pushed as crhacky/blhackbox:" + required: true + type: string + dockerfile: + description: "Path to the Dockerfile (build context is the repo root)" + required: true + type: string + tag_prefix: + description: "Metadata tag prefix, e.g. 'kali-mcp-'" + required: true + type: string + scout: + description: "Run the Docker Scout CVE scan + SARIF upload for this image" + required: false + type: boolean + default: false + +env: + REGISTRY: docker.io + IMAGE_NAME: crhacky/blhackbox + +jobs: + build: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + with: + submodules: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to Docker Hub + env: + DOCKERHUB_USERNAME: crhacky + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + max_attempts=5 + attempt=1 + backoff=3 + while [ "$attempt" -le "$max_attempts" ]; do + echo "Docker login attempt ${attempt}/${max_attempts}..." + if echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin 2>&1; then + echo "Docker login succeeded on attempt ${attempt}" + exit 0 + fi + if [ "$attempt" -lt "$max_attempts" ]; then + echo "Login failed, retrying in ${backoff}s..." + sleep "$backoff" + backoff=$((backoff * 2)) + fi + attempt=$((attempt + 1)) + done + echo "::error::Docker login failed after ${max_attempts} attempts" + exit 1 + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + prefix=${{ inputs.tag_prefix }},onlatest=true + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=${{ inputs.tag_prefix }} + type=raw,value=${{ inputs.tag_prefix }}latest,enable={{is_default_branch}} + + - name: Build and push ${{ inputs.service }} + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ${{ inputs.dockerfile }} + push: true + tags: crhacky/blhackbox:${{ inputs.service }} + + - name: Docker Scout - Vulnerability Scan + uses: docker/scout-action@v1 + if: ${{ always() && inputs.scout }} + with: + command: cves + image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + sarif-file: sarif.output.json + summary: true + continue-on-error: true + + - name: Upload SARIF to GitHub Security + uses: github/codeql-action/upload-sarif@v4 + if: ${{ always() && inputs.scout }} + with: + sarif_file: sarif.output.json + continue-on-error: true diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 37b69af..a80e9ff 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -1,139 +1,75 @@ name: Build and Push Docker Images +# Release + manual publishing. Per-component builds on push to main/master are +# handled by ci.yml (after tests pass). This workflow rebuilds the FULL image set +# for a clean release, or a single image on demand. on: - # Auto-trigger: fires after CI workflow completes on main/master - workflow_run: - workflows: ["CI"] - types: [completed] - branches: [main, master] - - # Auto-trigger: version tags bypass CI wait (tag push implies tested code) + # Version tags publish the complete, consistent image set. push: tags: ["v*"] - # Manual trigger + # Manual trigger: build everything, or a single chosen image. workflow_dispatch: - -env: - REGISTRY: docker.io - IMAGE_NAME: crhacky/blhackbox + inputs: + service: + description: "Image to build" + type: choice + default: all + options: + - all + - kali-mcp + - wire-mcp + - screenshot-mcp + - hexstrike-ai + - hexstrike-mcp + - boaz-mcp + - claude-code jobs: - build-and-push: + # Resolve the build matrix from the canonical registry: every image for a tag / + # "all" dispatch, or just the one requested via workflow_dispatch. + select: runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + any_changed: ${{ steps.select.outputs.any_changed }} - if: > - github.event_name == 'workflow_dispatch' || - github.event_name == 'push' || - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + steps: + - name: Checkout repository + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + - name: Select images to build + id: select + env: + SERVICE: ${{ github.event.inputs.service }} + run: | + set -euo pipefail + if [ -n "${SERVICE:-}" ] && [ "${SERVICE}" != "all" ]; then + out="$(python3 scripts/select_build_matrix.py --service "${SERVICE}")" + else + out="$(python3 scripts/select_build_matrix.py --all)" + fi + echo "Selection: ${out}" >&2 + { + echo "matrix=$(echo "${out}" | jq -c '.matrix')" + echo "any_changed=$(echo "${out}" | jq -r '.any_changed')" + } >> "$GITHUB_OUTPUT" + + call-build: + needs: select + if: needs.select.outputs.any_changed == 'true' permissions: contents: read packages: write security-events: write - strategy: fail-fast: false matrix: - include: - - service: kali-mcp - dockerfile: docker/kali-mcp.Dockerfile - tag_prefix: "kali-mcp-" - scout: false - - service: wire-mcp - dockerfile: docker/wire-mcp.Dockerfile - tag_prefix: "wire-mcp-" - scout: false - - service: screenshot-mcp - dockerfile: docker/screenshot-mcp.Dockerfile - tag_prefix: "screenshot-mcp-" - scout: false - - service: claude-code - dockerfile: docker/claude-code.Dockerfile - tag_prefix: "claude-code-" - scout: false - - service: hexstrike-ai - dockerfile: docker/hexstrike-ai.Dockerfile - tag_prefix: "hexstrike-ai-" - scout: false - - service: hexstrike-mcp - dockerfile: docker/hexstrike-mcp.Dockerfile - tag_prefix: "hexstrike-mcp-" - scout: false - - service: boaz-mcp - dockerfile: docker/boaz-mcp.Dockerfile - tag_prefix: "boaz-mcp-" - scout: false - - steps: - - name: Checkout repository - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 - with: - submodules: false - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || '' }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Log in to Docker Hub - env: - DOCKERHUB_USERNAME: crhacky - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - run: | - max_attempts=5 - attempt=1 - backoff=3 - while [ "$attempt" -le "$max_attempts" ]; do - echo "Docker login attempt ${attempt}/${max_attempts}..." - if echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin 2>&1; then - echo "Docker login succeeded on attempt ${attempt}" - exit 0 - fi - if [ "$attempt" -lt "$max_attempts" ]; then - echo "Login failed, retrying in ${backoff}s..." - sleep "$backoff" - backoff=$((backoff * 2)) - fi - attempt=$((attempt + 1)) - done - echo "::error::Docker login failed after ${max_attempts} attempts" - exit 1 - - - name: Extract metadata (tags, labels) - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - flavor: | - prefix=${{ matrix.tag_prefix }},onlatest=true - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=${{ matrix.tag_prefix }} - type=raw,value=${{ matrix.tag_prefix }}latest,enable={{is_default_branch}} - - - name: Build and push ${{ matrix.service }} - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: ${{ matrix.dockerfile }} - push: true - tags: crhacky/blhackbox:${{ matrix.service }} - - - name: Docker Scout - Vulnerability Scan - uses: docker/scout-action@v1 - if: ${{ always() && matrix.scout }} - with: - command: cves - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} - sarif-file: sarif.output.json - summary: true - continue-on-error: true - - - name: Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@v4 - if: ${{ always() && matrix.scout }} - with: - sarif_file: sarif.output.json - continue-on-error: true + include: ${{ fromJSON(needs.select.outputs.matrix) }} + uses: ./.github/workflows/_build-images.yml + with: + service: ${{ matrix.service }} + dockerfile: ${{ matrix.dockerfile }} + tag_prefix: ${{ matrix.tag_prefix }} + scout: ${{ matrix.scout }} + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8354b1..ef84cea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,66 @@ jobs: # No upstream fix available yet. Remove --ignore-vuln when pygments patches. # https://nvd.nist.gov/vuln/detail/CVE-2026-4539 run: pip-audit -r requirements.txt --ignore-vuln CVE-2026-4539 + + # Map the files changed in this push to the affected Docker images. Runs in the + # push context so the diff base (github.event.before) is reliable — workflow_run + # cannot do this because it ignores paths filters and exposes no "before" SHA. + detect-changes: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + any_changed: ${{ steps.select.outputs.any_changed }} + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Select changed images + id: select + env: + EVENT: ${{ github.event_name }} + BEFORE: ${{ github.event.before }} + HEAD: ${{ github.sha }} + run: | + set -euo pipefail + zero=0000000000000000000000000000000000000000 + if [ "${EVENT}" != "push" ]; then + # Only pushes publish images; PRs run test/security but never build. + out='{"matrix":[],"any_changed":false}' + elif [ "${BEFORE}" = "${zero}" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + echo "No reliable diff base (new branch / force-push) — selecting all images." >&2 + out="$(python3 scripts/select_build_matrix.py --all)" + else + out="$(git diff --name-only "${BEFORE}" "${HEAD}" | python3 scripts/select_build_matrix.py --changed -)" + fi + echo "Selection: ${out}" >&2 + { + echo "matrix=$(echo "${out}" | jq -c '.matrix')" + echo "any_changed=$(echo "${out}" | jq -r '.any_changed')" + } >> "$GITHUB_OUTPUT" + + # Build & push only the affected images, and only after tests pass on main/master. + # PRs run test/security but never reach this job (push-only guard). + call-build: + needs: [test, security, detect-changes] + if: > + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && + needs.detect-changes.outputs.any_changed == 'true' + permissions: + contents: read + packages: write + security-events: write + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.detect-changes.outputs.matrix) }} + uses: ./.github/workflows/_build-images.yml + with: + service: ${{ matrix.service }} + dockerfile: ${{ matrix.dockerfile }} + tag_prefix: ${{ matrix.tag_prefix }} + scout: ${{ matrix.scout }} + secrets: inherit diff --git a/CLAUDE.md b/CLAUDE.md index e317af3..8e4d36e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,10 +142,11 @@ Follow the existing conventions in the repo — don't invent new patterns. Befor 2. Write a Dockerfile matching project conventions (non-root user, health check required, minimal base image) 3. Implement the server using the same MCP framework and transport the other servers use 4. Add the service to `docker-compose.yml` under the appropriate profile -5. Add a `make logs-` target to `Makefile` (use backticks around `` in docs so it renders) -6. Update `.mcp.json` if the server should also be available in the Claude Code Web path -7. Document the exposed tools in the README components table -8. Add unit tests — at minimum, Pydantic validation tests for every tool input +5. Register the image in `.github/build-matrix.json` (`service`, `dockerfile`, `tag_prefix`, `paths`) so CI builds and publishes it — and only rebuilds it when its `paths` change +6. Add a `make logs-` target to `Makefile` (use backticks around `` in docs so it renders) +7. Update `.mcp.json` if the server should also be available in the Claude Code Web path +8. Document the exposed tools in the README components table +9. Add unit tests — at minimum, Pydantic validation tests for every tool input --- diff --git a/README.md b/README.md index c153061..3a3ce8b 100644 --- a/README.md +++ b/README.md @@ -1087,7 +1087,8 @@ blhackbox/ ├── tests/ └── .github/workflows/ ├── ci.yml - └── build-and-push.yml + ├── build-and-push.yml + └── _build-images.yml reusable single-image build (driven by .github/build-matrix.json) ``` --- diff --git a/scripts/select_build_matrix.py b/scripts/select_build_matrix.py new file mode 100644 index 0000000..23dc844 --- /dev/null +++ b/scripts/select_build_matrix.py @@ -0,0 +1,97 @@ +"""Select which Docker images to (re)build from a set of changed files. + +Reads the canonical component registry (.github/build-matrix.json) and prints a +compact JSON object ``{"matrix": [...components...], "any_changed": }`` for +the GitHub Actions build matrix. + +Modes: + --all select every component (release tags / manual "build all") + --service NAME select a single component by service name (manual dispatch) + --changed FILE read changed paths (one per line) from FILE, or '-' for stdin, + and select the components whose ``paths`` globs match + +Used by .github/workflows/ci.yml (per-component builds on push to main) and +.github/workflows/build-and-push.yml (release/manual builds). +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY = ROOT / ".github" / "build-matrix.json" + + +def load_components() -> list[dict]: + """Return the component objects from the canonical registry.""" + data = json.loads(REGISTRY.read_text(encoding="utf-8")) + return data["components"] + + +def path_matches(glob: str, changed: str) -> bool: + """Match a registry glob against a single changed file path. + + Two forms are supported: an exact path (``docker/kali-mcp.Dockerfile``) or a + recursive directory prefix ending in ``/**`` (``kali-mcp/**``). + """ + if glob.endswith("/**"): + prefix = glob[:-2] # "kali-mcp/**" -> "kali-mcp/" + return changed == prefix.rstrip("/") or changed.startswith(prefix) + return changed == glob + + +def select_changed(components: list[dict], changed_files: list[str]) -> list[dict]: + """Return components with at least one ``paths`` glob matching a changed file.""" + return [ + comp + for comp in components + if any(path_matches(glob, changed) for glob in comp["paths"] for changed in changed_files) + ] + + +def emit(selected: list[dict]) -> None: + """Print the compact matrix payload to stdout.""" + payload = {"matrix": selected, "any_changed": bool(selected)} + print(json.dumps(payload, separators=(",", ":"))) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Select Docker images to rebuild.") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--all", action="store_true", help="Select every component.") + group.add_argument("--service", metavar="NAME", help="Select one component by service name.") + group.add_argument( + "--changed", + metavar="FILE", + help="Read changed paths from FILE (or '-' for stdin) and select matching components.", + ) + args = parser.parse_args(argv) + + components = load_components() + + if args.all: + emit(components) + return 0 + + if args.service: + match = [comp for comp in components if comp["service"] == args.service] + if not match: + valid = ", ".join(comp["service"] for comp in components) + parser.error(f"unknown service '{args.service}'. Valid services: {valid}") + emit(match) + return 0 + + if args.changed == "-": + text = sys.stdin.read() + else: + text = Path(args.changed).read_text(encoding="utf-8") + changed_files = [line.strip() for line in text.splitlines() if line.strip()] + emit(select_changed(components, changed_files)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_docker_publish_matrix.py b/tests/test_docker_publish_matrix.py index 9674195..585556a 100644 --- a/tests/test_docker_publish_matrix.py +++ b/tests/test_docker_publish_matrix.py @@ -1,28 +1,53 @@ -"""Checks that compose images are covered by Docker Hub publishing.""" +"""Checks that the Docker publish pipeline covers all compose images, that the +build actions stay node24-ready, and that the per-component build selection maps +changed files to the right images.""" from __future__ import annotations +import json import re +import subprocess +import sys from pathlib import Path ROOT = Path(__file__).parent.parent COMPOSE = ROOT / "docker-compose.yml" +REGISTRY = ROOT / ".github" / "build-matrix.json" +REUSABLE_BUILD = ROOT / ".github" / "workflows" / "_build-images.yml" BUILD_WORKFLOW = ROOT / ".github" / "workflows" / "build-and-push.yml" +SELECT_SCRIPT = ROOT / "scripts" / "select_build_matrix.py" + + +def _registry() -> dict: + return json.loads(REGISTRY.read_text(encoding="utf-8")) + + +def _services() -> set[str]: + return {component["service"] for component in _registry()["components"]} + + +def _run_select(*args: str, stdin: str = "") -> dict: + proc = subprocess.run( + [sys.executable, str(SELECT_SCRIPT), *args], + input=stdin, + capture_output=True, + text=True, + check=True, + ) + return json.loads(proc.stdout) def test_compose_blhackbox_images_are_in_publish_matrix() -> None: compose = COMPOSE.read_text(encoding="utf-8") - workflow = BUILD_WORKFLOW.read_text(encoding="utf-8") - compose_tags = set(re.findall(r"image:\s*crhacky/blhackbox:([a-z0-9-]+)", compose)) - published_services = set(re.findall(r"service:\s*([a-z0-9-]+)", workflow)) assert compose_tags - assert compose_tags <= published_services + assert compose_tags <= _services() def test_build_workflow_uses_node24_ready_actions() -> None: - workflow = BUILD_WORKFLOW.read_text(encoding="utf-8") + # The pinned docker actions now live in the reusable build workflow. + workflow = REUSABLE_BUILD.read_text(encoding="utf-8") legacy_actions = [ "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", @@ -35,3 +60,83 @@ def test_build_workflow_uses_node24_ready_actions() -> None: for version in ["# v6.0.3", "# v4.1.0", "# v6.1.0", "# v7.2.0"]: assert version in workflow + + +def test_registry_dockerfiles_exist_and_self_reference() -> None: + for component in _registry()["components"]: + dockerfile = ROOT / component["dockerfile"] + assert dockerfile.is_file(), f"missing {component['dockerfile']}" + # A change to a component's own Dockerfile must always rebuild that image. + assert component["dockerfile"] in component["paths"], ( + f"{component['service']} paths must include its own Dockerfile" + ) + # JSON boolean keeps the reusable workflow's `if: inputs.scout` correct. + assert component["scout"] is False + + +def test_dispatch_choice_offers_every_service() -> None: + workflow = BUILD_WORKFLOW.read_text(encoding="utf-8") + options = set(re.findall(r"^\s*-\s+([a-z0-9-]+)\s*$", workflow, re.MULTILINE)) + + assert "all" in options + assert _services() <= options + + +def test_select_all_returns_every_component() -> None: + result = _run_select("--all") + + assert result["any_changed"] is True + assert {component["service"] for component in result["matrix"]} == _services() + + +def test_select_single_service() -> None: + result = _run_select("--service", "kali-mcp") + + assert result["any_changed"] is True + assert [component["service"] for component in result["matrix"]] == ["kali-mcp"] + + +def test_select_unknown_service_errors() -> None: + proc = subprocess.run( + [sys.executable, str(SELECT_SCRIPT), "--service", "does-not-exist"], + capture_output=True, + text=True, + ) + assert proc.returncode != 0 + + +def test_select_changed_docs_only_builds_nothing() -> None: + result = _run_select("--changed", "-", stdin="README.md\ndocs/guide.md\nLICENSE\n") + + assert result["any_changed"] is False + assert result["matrix"] == [] + + +def test_select_changed_core_and_tests_build_nothing() -> None: + # No image bakes in the core package or tests, so these rebuild nothing. + changed = "blhackbox/core/x.py\ntests/test_x.py\nMakefile\ndocker-compose.yml\n" + result = _run_select("--changed", "-", stdin=changed) + + assert result["any_changed"] is False + + +def test_select_changed_maps_files_to_components() -> None: + cases = { + "kali-mcp/server.py": "kali-mcp", + "kali-mcp/requirements.txt": "kali-mcp", + "docker/kali-mcp.Dockerfile": "kali-mcp", + "screenshot-mcp/server.py": "screenshot-mcp", + "CLAUDE.md": "claude-code", + ".claude/skills/recon/SKILL.md": "claude-code", + "docker/claude-code-entrypoint.sh": "claude-code", + "docker/hexstrike-ai.Dockerfile": "hexstrike-ai", + } + for changed, expected in cases.items(): + result = _run_select("--changed", "-", stdin=changed + "\n") + assert [component["service"] for component in result["matrix"]] == [expected], changed + + +def test_select_changed_multiple_components_union() -> None: + result = _run_select("--changed", "-", stdin="kali-mcp/server.py\nboaz-mcp/server.py\n") + + assert {component["service"] for component in result["matrix"]} == {"kali-mcp", "boaz-mcp"}