diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e80f11..a88f579 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,20 @@ on: workflow_dispatch: jobs: + # Unit tests for the skyhook config resolver. These run in seconds and don't + # need Docker, so they're the first line of defence against regressions in + # field-name handling, root-vs-service precedence, and the override semantics + # of `resolved_context` / `resolved_dockerfile`. ubuntu-latest ships yq v4. + test-skyhook-resolver-unit: + runs-on: ubuntu-latest + name: Unit tests — skyhook config resolver + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run resolver unit tests + run: bash test/unit/test_resolve_skyhook_config.sh + test-basic-build: runs-on: ubuntu-latest name: Test basic Docker build diff --git a/.skyhook/skyhook.yaml b/.skyhook/skyhook.yaml index 4eaac1d..4c04e75 100644 --- a/.skyhook/skyhook.yaml +++ b/.skyhook/skyhook.yaml @@ -1,12 +1,35 @@ -# Test configuration for skyhook config resolution tests -# All paths are relative to the repository root +# Test configuration for skyhook config resolution tests. +# All paths are repo-root-relative (canonical schema, see +# koala-backend/internal/conf/skyhook.go::SkyhookDockerBuild). +# +# Exercises every branch of the resolver: +# - canonical per-service `buildContext` (web) +# - legacy per-service `contextPath` (api, worker) — back-compat path +# - root-level `buildContext` inherited by service (inherits-root) +# - service with no buildTool anywhere (simple) — defers to inputs + +buildTool: + docker: + # Root-level default. Services that omit buildTool entirely should + # inherit this. Per-service overrides take precedence. + buildContext: test/services/shared + services: + - name: web + path: test/services/web + buildTool: + docker: + # Canonical field name. Should win over the root buildContext above. + buildContext: test/services/web + - name: api path: test/services/api deploymentRepo: my-org/deployment deploymentRepoPath: api buildTool: docker: + # Legacy field name. Kept here so the resolver's deprecation / + # back-compat path stays exercised in tests. contextPath: test/services/api - name: worker @@ -16,6 +39,11 @@ services: contextPath: test/services/worker dockerfilePath: test/services/worker/docker/Dockerfile + - name: inherits-root + path: test/services/inherits-root + # No per-service buildTool — should fall back to the root buildContext. + - name: simple path: test/services/simple - # No buildTool defined - should use path as context fallback + # No per-service buildTool — same as inherits-root above; documents that + # the legacy fixture name still works without changes after the rewrite. diff --git a/README.md b/README.md index 13d4a63..21c45ff 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,11 @@ The simplest way to use this action - just provide the image repository and prim *Must provide either (`image` + `base_tag`) OR `tags` +#### Skyhook Config (auto-resolve context + Dockerfile) +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `service_name` | Name of a service defined in `.skyhook/skyhook.yaml`. When set, `context` and `dockerfile` are resolved from that file (see [Skyhook Config](#skyhook-config) below). | No | - | + ### Build Configuration | Input | Description | Required | Default | @@ -169,6 +174,71 @@ All parameters prefixed with `buildx_` are passed directly to docker/setup-build | `metadata` | Build result metadata | | `tags_list` | Newline-delimited list of image:tag combinations | +## Skyhook Config + +When `service_name` is set, the action reads `.skyhook/skyhook.yaml` from the **calling workflow's checkout directory** (which must live under `code/` — i.e. `actions/checkout` is expected to have placed the source under `./code`) and uses it to derive the build context and Dockerfile. The explicit `context` / `dockerfile` inputs are ignored in this mode. + +### Schema + +The action looks at `buildTool.docker.{buildContext,dockerfilePath}` at two levels — root (applies to every service) and per-service (overrides the root): + +```yaml +# .skyhook/skyhook.yaml +buildTool: + docker: + buildContext: shared # root-level default for every service + dockerfilePath: shared/Dockerfile + +services: + - name: api + path: apps/api # used as a fallback for Dockerfile only + + - name: worker + path: apps/worker + buildTool: + docker: + buildContext: apps/worker/src # per-service override + dockerfilePath: apps/worker/docker/Dockerfile +``` + +All paths are repo-root-relative. `.` and `./` are normalised to "no override" — use them when you mean "fall through to the next step in the chain". + +### Resolution chain + +| step | context | dockerfile | +|-------|-------------------------------|-----------------------------------------------| +| 1 | per-service `buildContext` | per-service `dockerfilePath` | +| 2 | root `buildContext` | root `dockerfilePath` | +| 3 | _(no further fallback)_ | `/Dockerfile` | +| 4 | `code` (entire checkout) | `code/Dockerfile` | + +The two chains are independent: setting only `buildContext` does **not** make `dockerfile` resolve relative to it — `dockerfile` runs through its own chain. + +### Override semantics (important) + +Once `service_name` is set, the resolved values **always take precedence** over the action's own `context` / `dockerfile` inputs — even when no override is found in YAML and the chain falls through to `code` / `code/Dockerfile`. This is intentional so behaviour is predictable across the matrix of "service exists with overrides", "service exists without overrides", "service not found", and "no `.skyhook/skyhook.yaml` at all". + +If you want the calling workflow's `context` / `dockerfile` inputs to be honoured, **don't set `service_name`** (manual mode). + +### Deprecated field: `contextPath` + +`buildTool.docker.contextPath` is the legacy alias for `buildContext`. It is still honoured for backwards compatibility, but the action emits a one-shot warning and you should rename it. `dockerfilePath` has no historical alias. + +### Example + +```yaml +- uses: actions/checkout@v4 + with: + path: code # required: action expects sources under ./code + +- uses: skyhook-io/docker-build-push-action@v1 + with: + image: ghcr.io/${{ github.repository }} + base_tag: v1.2.3 + service_name: worker # everything else (context, dockerfile) + # comes from .skyhook/skyhook.yaml +``` + ## Examples ### Using Automatic Tag Generation @@ -233,6 +303,31 @@ All parameters prefixed with `buildx_` are passed directly to docker/setup-build push: true ``` +### Skyhook Config Mode + +```yaml +# .skyhook/skyhook.yaml in your repo: +# services: +# - name: api +# path: apps/api +# buildTool: +# docker: +# buildContext: apps/api +# dockerfilePath: apps/api/Dockerfile + +- uses: actions/checkout@v4 + with: + path: code + +- uses: skyhook-io/docker-build-push-action@v1 + with: + image: ghcr.io/${{ github.repository }} + base_tag: v1.2.3 + service_name: api # context + dockerfile come from .skyhook/skyhook.yaml +``` + +See [Skyhook Config](#skyhook-config) for the full schema, resolution chain, and override semantics. + ### Build with Build Arguments ```yaml diff --git a/action.yml b/action.yml index c7c75a1..7a7a4bf 100644 --- a/action.yml +++ b/action.yml @@ -198,76 +198,16 @@ runs: id: skyhook_config shell: bash working-directory: code - run: | - set -euo pipefail - - # Use the input if it exists; - if [[ -n "${{ inputs.service_name }}" ]]; then - SERVICE_NAME="${{ inputs.service_name }}" - fi - - # If no service_name provided, skip config resolution - if [[ -z "$SERVICE_NAME" ]]; then - echo "No service_name provided, skipping config resolution" - echo "resolved_context=code" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Find the config file - CONFIG_FILE="" - if [[ -f ".skyhook/skyhook.yaml" ]]; then - CONFIG_FILE=".skyhook/skyhook.yaml" - fi - - if [[ -z "$CONFIG_FILE" ]]; then - echo "::warning::service_name '$SERVICE_NAME' provided but .skyhook/skyhook.yaml not found. Falling back to input parameters." - echo "resolved_context=code" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "Found config file: $CONFIG_FILE" - - # Check if service exists - SERVICE_EXISTS=$(yq ".services[] | select(.name == \"$SERVICE_NAME\") | .name" "$CONFIG_FILE") - if [[ -z "$SERVICE_EXISTS" ]]; then - echo "::warning::Service '$SERVICE_NAME' not found in $CONFIG_FILE. Falling back to input parameters." - echo "resolved_context=code" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "Found service '$SERVICE_NAME' in config" - - # Extract build config using yq - # Context: only use buildTool.docker.contextPath if explicitly set, otherwise empty (defaults to repo root) - CONTEXT_PATH=$(yq ".services[] | select(.name == \"$SERVICE_NAME\") | .buildTool.docker.contextPath // \"\"" "$CONFIG_FILE") - DOCKERFILE_PATH=$(yq ".services[] | select(.name == \"$SERVICE_NAME\") | .buildTool.docker.dockerfilePath // \"\"" "$CONFIG_FILE") - - # Normalize outputs to repo root (this step runs in working-directory: code) - REPO_PREFIX="code" - - # Context: if contextPath is set use code/{contextPath}, else use code - if [[ -n "$CONTEXT_PATH" ]]; then - RESOLVED_CONTEXT="$REPO_PREFIX/$CONTEXT_PATH" - - # If dockerfile is not set, default to {context}/Dockerfile - if [[ -z "$DOCKERFILE_PATH" ]]; then - DOCKERFILE_PATH="${CONTEXT_PATH}/Dockerfile" - echo "Defaulting dockerfile to: $REPO_PREFIX/$DOCKERFILE_PATH" - fi - else - RESOLVED_CONTEXT="$REPO_PREFIX" - fi - echo "Using context: $RESOLVED_CONTEXT" - echo "resolved_context=$RESOLVED_CONTEXT" >> "$GITHUB_OUTPUT" - - if [[ -n "$DOCKERFILE_PATH" ]]; then - FULL_DOCKERFILE_PATH="$REPO_PREFIX/$DOCKERFILE_PATH" - echo "Using dockerfile from config: $FULL_DOCKERFILE_PATH" - echo "resolved_dockerfile=$FULL_DOCKERFILE_PATH" >> "$GITHUB_OUTPUT" - fi - - echo "config_file=$CONFIG_FILE" >> "$GITHUB_OUTPUT" - echo "service_name=$SERVICE_NAME" >> "$GITHUB_OUTPUT" + env: + SERVICE_NAME: ${{ inputs.service_name }} + # Path prefix the consumer expects the resolved values to live under. + # This step runs inside `code/` (the calling workflow's checkout dir), + # so any path read from skyhook.yaml is repo-root-relative and gets + # `code/` prepended before being emitted as an output. + REPO_PREFIX: code + # Resolver lives in scripts/ so it can be unit-tested independently of + # GitHub Actions (see test/unit/test_resolve_skyhook_config.sh). + run: bash "$GITHUB_ACTION_PATH/scripts/resolve_skyhook_config.sh" - name: Validate inputs shell: bash diff --git a/scripts/resolve_skyhook_config.sh b/scripts/resolve_skyhook_config.sh new file mode 100755 index 0000000..5235e14 --- /dev/null +++ b/scripts/resolve_skyhook_config.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# +# Resolve Docker build context + Dockerfile from `.skyhook/skyhook.yaml`. +# +# Resolution chain (mirrors koala-backend/.../build_image.yml — PR #1319): +# +# step │ context │ dockerfile +# ─────┼───────────────────────────┼────────────────────────────────── +# 1 │ per-service buildContext │ per-service dockerfilePath +# 2 │ root buildContext │ root dockerfilePath +# 3 │ code │ code//Dockerfile +# 4 │ code │ code/Dockerfile (no service.path) +# +# YAML values are repo-root-relative ("absolute from repo root"); the script +# only ever prepends `$REPO_PREFIX` (the calling workflow's checkout dir). +# +# Field names: prefer canonical `buildContext`; fall back to legacy +# `contextPath` for back-compat (with a deprecation warning). `dockerfilePath` +# has no historical alias. +# +# The "SERVICE_DIR" used in step 3 of the dockerfile chain is read from +# `services[name=$SERVICE_NAME].path` in skyhook.yaml — the action does not +# need it as a separate input. If the service has no `path`, step 3 is +# skipped and step 4 (`code/Dockerfile`) wins. +# +# Inputs (env vars): +# SERVICE_NAME Service name to look up. If empty, the script is a no-op +# (manual mode — caller's `inputs.context` / `inputs.dockerfile` +# are used as-is). +# REPO_PREFIX Path the consumer expects values to live under (default: "code"). +# SKYHOOK_FILE Optional override for the config file path +# (default: ".skyhook/skyhook.yaml", resolved relative to +# the working directory the action runs the script in). +# GITHUB_OUTPUT File to append `key=value` outputs to (GHA-compatible). +# If unset, outputs go to stdout instead. +# +# Requires `yq v4` (Mike Farah's Go yq) on PATH. `strenv()` is yq v4's env-var +# injection primitive — the jq-style `--arg` flag is NOT supported on yq v4 +# and silently produces wrong queries. + +set -euo pipefail + +: "${SERVICE_NAME:=}" +: "${REPO_PREFIX:=code}" +: "${SKYHOOK_FILE:=.skyhook/skyhook.yaml}" + +emit() { + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s\n' "$1" >> "$GITHUB_OUTPUT" + else + printf '%s\n' "$1" + fi +} + +log() { printf '%s\n' "$*" >&2; } + +# yq_get — returns "" for missing/null and normalizes "."/"./". +# Stderr from yq is intentionally NOT redirected: malformed YAML or yq errors +# should surface in the job log instead of silently falling through to +# defaults. `|| true` keeps the script alive on a non-zero exit so the +# resolution chain can still finish. +yq_get() { + local q=$1 file=$2 out + out=$(yq "$q" "$file" || true) + # yq emits the literal string "null" when a path resolves to a missing key + # and `// ""` did not absorb it (e.g. when a parent path is itself absent). + [[ "$out" == "null" ]] && out="" + # Treat "." and "./" as "no override": both `koala-backend` (canonical + # resolver) and the workflow-side resolver in `build_image.yml` collapse + # them. Keeping the same semantic here so all three layers agree. + case "$out" in .|./) out="" ;; esac + # Strip a leading `./` so we emit `code/src` instead of `code/./src`. + out=${out#./} + printf '%s' "$out" +} + +# Manual mode: nothing to resolve, leave inputs alone. +if [[ -z "$SERVICE_NAME" ]]; then + log "No service_name provided; skipping skyhook config resolution" + exit 0 +fi + +# Pull root-level overrides up front: they apply even when no per-service +# config (or no service entry) exists. +ROOT_CTX="" +ROOT_CTX_LEGACY="" +ROOT_DFP="" +SVC_CTX="" +SVC_CTX_LEGACY="" +SVC_DFP="" +SVC_PATH="" +CONFIG_PRESENT=0 + +if [[ -f "$SKYHOOK_FILE" ]]; then + CONFIG_PRESENT=1 + log "Found config file: $SKYHOOK_FILE" + + ROOT_CTX=$(yq_get '.buildTool.docker.buildContext // ""' "$SKYHOOK_FILE") + ROOT_CTX_LEGACY=$(yq_get '.buildTool.docker.contextPath // ""' "$SKYHOOK_FILE") + ROOT_DFP=$(yq_get '.buildTool.docker.dockerfilePath // ""' "$SKYHOOK_FILE") + + SERVICE_EXISTS=$(yq '(.services // []) | map(select(.name == strenv(SERVICE_NAME))) | .[0].name // ""' "$SKYHOOK_FILE" || true) + [[ "$SERVICE_EXISTS" == "null" ]] && SERVICE_EXISTS="" + if [[ -n "$SERVICE_EXISTS" ]]; then + log "Found service '$SERVICE_NAME' in config" + SVC_CTX=$(yq_get '(.services // []) | map(select(.name == strenv(SERVICE_NAME))) | (.[0].buildTool.docker.buildContext // "")' "$SKYHOOK_FILE") + SVC_CTX_LEGACY=$(yq_get '(.services // []) | map(select(.name == strenv(SERVICE_NAME))) | (.[0].buildTool.docker.contextPath // "")' "$SKYHOOK_FILE") + SVC_DFP=$(yq_get '(.services // []) | map(select(.name == strenv(SERVICE_NAME))) | (.[0].buildTool.docker.dockerfilePath // "")' "$SKYHOOK_FILE") + SVC_PATH=$(yq_get '(.services // []) | map(select(.name == strenv(SERVICE_NAME))) | (.[0].path // "")' "$SKYHOOK_FILE") + else + log "::warning::Service '$SERVICE_NAME' not found in $SKYHOOK_FILE; only root-level overrides (if any) will apply." + fi +else + log "::warning::service_name '$SERVICE_NAME' was provided but $SKYHOOK_FILE was not found; using code defaults." +fi + +# One-shot deprecation warning if anyone is still on `contextPath` AND the +# canonical name didn't already win at the same scope. +if { [[ -z "$SVC_CTX" && -n "$SVC_CTX_LEGACY" ]] || [[ -z "$SVC_CTX" && -z "$ROOT_CTX" && -n "$ROOT_CTX_LEGACY" ]]; }; then + log "::warning::buildTool.docker.contextPath is deprecated; rename to buildContext (see https://github.com/skyhook-io/docker-build-push-action#skyhook-config)." +fi + +# ── Context chain ─────────────────────────────────────────────────────────── +# per-service > root > "code" (no SERVICE_DIR step on context — the build +# context defaults to the entire checkout, and `.dockerignore` / Dockerfile +# COPY paths govern what's actually included). +CTX="${SVC_CTX:-${SVC_CTX_LEGACY:-${ROOT_CTX:-$ROOT_CTX_LEGACY}}}" +if [[ -n "$CTX" ]]; then + RESOLVED_CONTEXT="$REPO_PREFIX/$CTX" +else + RESOLVED_CONTEXT="$REPO_PREFIX" +fi +log "Using context: $RESOLVED_CONTEXT" +emit "resolved_context=$RESOLVED_CONTEXT" + +# ── Dockerfile chain ──────────────────────────────────────────────────────── +# per-service > root > /Dockerfile > Dockerfile. +# `service.path` is read from skyhook.yaml — no separate input needed. +DFP="${SVC_DFP:-$ROOT_DFP}" +if [[ -n "$DFP" ]]; then + RESOLVED_DOCKERFILE="$REPO_PREFIX/$DFP" +elif [[ -n "$SVC_PATH" ]]; then + RESOLVED_DOCKERFILE="$REPO_PREFIX/$SVC_PATH/Dockerfile" +else + RESOLVED_DOCKERFILE="$REPO_PREFIX/Dockerfile" +fi +log "Using dockerfile: $RESOLVED_DOCKERFILE" +emit "resolved_dockerfile=$RESOLVED_DOCKERFILE" + +# Diagnostic outputs (consumed by the action's step summary). +if [[ "$CONFIG_PRESENT" == "1" ]]; then + emit "config_file=$SKYHOOK_FILE" +fi +emit "service_name=$SERVICE_NAME" diff --git a/test/unit/fixtures/canonical-only.yaml b/test/unit/fixtures/canonical-only.yaml new file mode 100644 index 0000000..004b5d5 --- /dev/null +++ b/test/unit/fixtures/canonical-only.yaml @@ -0,0 +1,7 @@ +services: + - name: web + path: apps/web + buildTool: + docker: + buildContext: apps/web/src + dockerfilePath: apps/web/docker/Dockerfile diff --git a/test/unit/fixtures/dotslash-context.yaml b/test/unit/fixtures/dotslash-context.yaml new file mode 100644 index 0000000..ed0cd4f --- /dev/null +++ b/test/unit/fixtures/dotslash-context.yaml @@ -0,0 +1,10 @@ +services: + - name: monorepo-root + path: apps/foo + buildTool: + docker: + # `./` and `.` are normalized to "no override" by the resolver — same as + # koala-backend's SkyhookConfig.ResolveBuildContext and the workflow-side + # resolver in build_image.yml. Documented limitation. + buildContext: ./ + dockerfilePath: apps/foo/Dockerfile diff --git a/test/unit/fixtures/legacy-contextpath.yaml b/test/unit/fixtures/legacy-contextpath.yaml new file mode 100644 index 0000000..85f1d26 --- /dev/null +++ b/test/unit/fixtures/legacy-contextpath.yaml @@ -0,0 +1,6 @@ +services: + - name: legacy + path: apps/legacy + buildTool: + docker: + contextPath: apps/legacy diff --git a/test/unit/fixtures/malformed.yaml b/test/unit/fixtures/malformed.yaml new file mode 100644 index 0000000..3591404 --- /dev/null +++ b/test/unit/fixtures/malformed.yaml @@ -0,0 +1,2 @@ +::: not yaml ::: +- "[unterminated diff --git a/test/unit/fixtures/no-buildtool-anywhere.yaml b/test/unit/fixtures/no-buildtool-anywhere.yaml new file mode 100644 index 0000000..ab96d27 --- /dev/null +++ b/test/unit/fixtures/no-buildtool-anywhere.yaml @@ -0,0 +1,7 @@ +services: + # Service has `path` set — used as the SERVICE_DIR fallback for dockerfile. + - name: bare-with-path + path: apps/bare + + # Service has no `path` — dockerfile falls all the way through to code/Dockerfile. + - name: bare-no-path diff --git a/test/unit/fixtures/root-and-services.yaml b/test/unit/fixtures/root-and-services.yaml new file mode 100644 index 0000000..72bb74a --- /dev/null +++ b/test/unit/fixtures/root-and-services.yaml @@ -0,0 +1,15 @@ +buildTool: + docker: + buildContext: shared + dockerfilePath: shared/Dockerfile + +services: + - name: web + path: apps/web + buildTool: + docker: + buildContext: apps/web/src + + - name: api + path: apps/api + # No per-service buildTool — should inherit the root values. diff --git a/test/unit/test_resolve_skyhook_config.sh b/test/unit/test_resolve_skyhook_config.sh new file mode 100755 index 0000000..cf0c3a1 --- /dev/null +++ b/test/unit/test_resolve_skyhook_config.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# +# Unit tests for scripts/resolve_skyhook_config.sh. +# +# Each case sets env vars for the resolver, runs it against a fixture YAML in +# fixtures/, and asserts the contents written to a fake $GITHUB_OUTPUT. +# +# Run from anywhere: +# bash test/unit/test_resolve_skyhook_config.sh +# +# Requires `yq v4` on PATH (same requirement as the action itself). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +RESOLVER="$REPO_ROOT/scripts/resolve_skyhook_config.sh" +FIXTURES="$REPO_ROOT/test/unit/fixtures" +TMPROOT="$(mktemp -d)" +trap 'rm -rf "$TMPROOT"' EXIT + +[[ -x "$RESOLVER" ]] || chmod +x "$RESOLVER" + +if ! command -v yq >/dev/null; then + echo "yq is required" >&2 + exit 2 +fi +if ! yq --version 2>&1 | grep -q 'version v4'; then + echo "yq v4 is required, found: $(yq --version 2>&1)" >&2 + exit 2 +fi + +pass=0; fail=0 + +# run_case [expected_stderr_pattern] +# +# expected_outputs is a `;`-separated list of grep-E patterns that must each +# match a line in $GITHUB_OUTPUT. Prefix a pattern with `!` to assert it MUST +# NOT match. expected_stderr_pattern, if non-empty, must match somewhere in +# the resolver's stderr (used to assert that errors/warnings actually surface +# to the job log instead of being silently swallowed). +run_case() { + local name=$1 fixture=$2 svc=$3 expects=$4 expect_stderr=${5:-} + local case_dir out_file + case_dir="$TMPROOT/$name" + mkdir -p "$case_dir" + out_file="$case_dir/github_output" + : > "$out_file" + + SERVICE_NAME="$svc" \ + REPO_PREFIX="code" \ + SKYHOOK_FILE="$FIXTURES/$fixture" \ + GITHUB_OUTPUT="$out_file" \ + bash "$RESOLVER" >"$case_dir/stdout" 2>"$case_dir/stderr" + + local ok=1 + local IFS=';' + for pattern in $expects; do + pattern="${pattern# }"; pattern="${pattern% }" + [[ -z "$pattern" ]] && continue + if [[ "${pattern:0:1}" == "!" ]]; then + local p="${pattern:1}" + if grep -Eq "$p" "$out_file"; then + printf ' FAIL %s: unexpected output line matching /%s/\n' "$name" "$p" + ok=0 + fi + else + if ! grep -Eq "$pattern" "$out_file"; then + printf ' FAIL %s: missing output line matching /%s/\n' "$name" "$pattern" + ok=0 + fi + fi + done + unset IFS + + if [[ -n "$expect_stderr" ]] && ! grep -Eq "$expect_stderr" "$case_dir/stderr"; then + printf ' FAIL %s: missing stderr line matching /%s/\n' "$name" "$expect_stderr" + ok=0 + fi + + if [[ "$ok" == "1" ]]; then + printf ' ok %s\n' "$name" + pass=$((pass + 1)) + else + printf ' ---- $GITHUB_OUTPUT ----\n' + sed 's/^/ /' "$out_file" + printf ' ---- stderr ----\n' + sed 's/^/ /' "$case_dir/stderr" + fail=$((fail + 1)) + fi +} + +echo "running unit tests against $RESOLVER" + +# ── Per-service overrides ────────────────────────────────────────────────── + +# 1. Canonical buildContext + dockerfilePath, both per-service. +run_case "canonical-fields-emitted" \ + "canonical-only.yaml" "web" \ + "^resolved_context=code/apps/web/src$ ; ^resolved_dockerfile=code/apps/web/docker/Dockerfile$" + +# 2. Legacy contextPath honored (back-compat path). When dockerfilePath is +# unset, dockerfile falls back to /Dockerfile (NOT to +# /Dockerfile; context and dockerfile chains are independent). +run_case "legacy-contextpath-honored" \ + "legacy-contextpath.yaml" "legacy" \ + "^resolved_context=code/apps/legacy$ ; ^resolved_dockerfile=code/apps/legacy/Dockerfile$" + +# 3. Per-service value overrides root. +run_case "service-overrides-root" \ + "root-and-services.yaml" "web" \ + "^resolved_context=code/apps/web/src$ ; !^resolved_context=code/shared$" + +# 4. Service with no per-service buildTool inherits root buildContext / dockerfilePath. +run_case "service-inherits-root" \ + "root-and-services.yaml" "api" \ + "^resolved_context=code/shared$ ; ^resolved_dockerfile=code/shared/Dockerfile$" + +# ── Fallback chain (no YAML override) ────────────────────────────────────── + +# 5. No buildTool anywhere, but service has `path` in YAML → context=code, +# dockerfile=code//Dockerfile. +run_case "no-override-with-service-path" \ + "no-buildtool-anywhere.yaml" "bare-with-path" \ + "^resolved_context=code$ ; ^resolved_dockerfile=code/apps/bare/Dockerfile$" + +# 6. No buildTool AND no service.path in YAML → context=code, dockerfile=code/Dockerfile. +run_case "no-override-no-service-path" \ + "no-buildtool-anywhere.yaml" "bare-no-path" \ + "^resolved_context=code$ ; ^resolved_dockerfile=code/Dockerfile$" + +# 7. `./` is normalized to "no override" — context falls through to default. +# (dockerfilePath in the fixture is explicitly set, so dockerfile uses that.) +run_case "dotslash-context-is-normalized" \ + "dotslash-context.yaml" "monorepo-root" \ + "^resolved_context=code$ ; ^resolved_dockerfile=code/apps/foo/Dockerfile$" + +# ── No-op edge cases ─────────────────────────────────────────────────────── + +# 8. Empty service_name → manual mode, no outputs at all. +run_case "no-service-name-is-noop" \ + "canonical-only.yaml" "" \ + "!^resolved_context= ; !^resolved_dockerfile= ; !^config_file= ; !^service_name=" + +# 9. Service not found in YAML → root-level overrides (none in this fixture) +# apply, no service.path is available, so dockerfile falls all the way +# through to code/Dockerfile. Diagnostic warning emitted (not asserted here). +run_case "unknown-service-uses-defaults" \ + "canonical-only.yaml" "does-not-exist" \ + "^resolved_context=code$ ; ^resolved_dockerfile=code/Dockerfile$ ; ^service_name=does-not-exist$" + +# 10. Malformed YAML must surface yq's parse error to stderr (i.e. to the GHA +# job log) instead of being silently swallowed. The resolver still falls +# through to defaults so the build can proceed — the contract is "noisy +# fallback", not "fail closed". +run_case "malformed-yaml-surfaces-error" \ + "malformed.yaml" "any" \ + "^resolved_context=code$ ; ^resolved_dockerfile=code/Dockerfile$" \ + "Error|error" + +# ── Docs-vs-code consistency ─────────────────────────────────────────────── + +# 11. The resolver's deprecation warning links to a `#skyhook-config` anchor +# in README.md. Make sure that anchor still exists — silent-rotting docs +# turn the warning into a 404 for every user hitting the legacy field. +readme_anchor_check() { + local readme="$REPO_ROOT/README.md" + local script="$RESOLVER" + local linked_anchor heading_slug + linked_anchor=$(grep -oE '#skyhook-config[^ )]*' "$script" | head -n1 || true) + if [[ -z "$linked_anchor" ]]; then + printf ' ok readme-anchor-for-deprecation-warning (no anchor referenced)\n' + pass=$((pass + 1)) + return + fi + # GitHub turns "## Skyhook Config" into anchor "#skyhook-config". + if grep -qE '^## +Skyhook Config *$' "$readme"; then + printf ' ok readme-anchor-for-deprecation-warning\n' + pass=$((pass + 1)) + else + printf ' FAIL readme-anchor-for-deprecation-warning: %s referenced from resolver but no matching `## Skyhook Config` heading in README.md\n' "$linked_anchor" + fail=$((fail + 1)) + fi +} +readme_anchor_check + +echo +if [[ "$fail" -eq 0 ]]; then + echo "ALL $pass UNIT TESTS PASSED" + exit 0 +else + echo "$fail/$((pass+fail)) UNIT TESTS FAILED" + exit 1 +fi