diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..083f550 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release +on: + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # semantic-release walks the full commit history to find the + # previous release tag and analyze commits since. Without a deep + # fetch the first run can pick the wrong base, and subsequent + # runs can mis-version or skip releases entirely. + fetch-depth: 0 + + - uses: codfish/semantic-release-action@v4 + with: + additional-packages: "['semantic-release-github-actions-tags']" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 158f355..d2e807c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: shell-tests: strategy: @@ -12,6 +15,11 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} + # The script + tests should finish in seconds on a healthy runner. Cap + # well below the default 6h so a future hang (the kind we just fixed + # in the delimiter generator) fails fast instead of burning runner + # hours waiting for the platform timeout. + timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -34,6 +42,7 @@ jobs: smoke: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -57,16 +66,54 @@ jobs: test "${{ steps.cfg.outputs.dockerfile_path }}" = "services/smoke/Dockerfile" echo "smoke-svc outputs OK" - - name: Run action against fixture (buildContext absent -> default '.') + - name: Run action against fixture (buildContext absent, no caller default) id: cfg2 uses: ./ with: working_directory: tests/fixtures service_name: smoke-default-context - - name: Verify default-context outputs + - name: Verify outputs when YAML lacks buildContext and no caller default run: | set -e - test "${{ steps.cfg2.outputs.build_context }}" = "." + # build_context comes from YAML when present; otherwise echoes whatever + # default_build_context the caller passed (here: nothing → empty). + test "${{ steps.cfg2.outputs.build_context }}" = "" test "${{ steps.cfg2.outputs.dockerfile_path }}" = "services/default/Dockerfile" - echo "default-context outputs OK" + echo "default-context outputs OK (empty fallback)" + + - name: Run action with caller-supplied default_* fallbacks + id: cfg3 + uses: ./ + with: + working_directory: tests/fixtures + service_name: smoke-default-context + default_build_context: 'services/default' + default_dockerfile_path: 'services/default/Custom.Dockerfile' + + - name: Verify caller defaults fill in YAML-absent fields + run: | + set -e + # buildContext is absent in YAML for smoke-default-context → fall back to caller-supplied default + test "${{ steps.cfg3.outputs.build_context }}" = "services/default" + # dockerfilePath IS in YAML → YAML wins, caller default is ignored + test "${{ steps.cfg3.outputs.dockerfile_path }}" = "services/default/Dockerfile" + echo "caller-default fallback OK" + + - name: Run action against missing service with caller defaults + id: cfg4 + uses: ./ + with: + working_directory: tests/fixtures + service_name: not-a-real-service + default_build_context: '.' + default_dockerfile_path: 'Dockerfile' + + - name: Verify caller defaults applied on missing service + run: | + set -e + test "${{ steps.cfg4.outputs.config_found }}" = "true" + test "${{ steps.cfg4.outputs.service_found }}" = "false" + test "${{ steps.cfg4.outputs.build_context }}" = "." + test "${{ steps.cfg4.outputs.dockerfile_path }}" = "Dockerfile" + echo "missing-service caller-default OK" diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..39045a4 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,10 @@ +{ + "branches": ["main"], + "tagFormat": "v${version}", + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/github", + "semantic-release-github-actions-tags" + ] +} diff --git a/README.md b/README.md index a4fba51..eb2859f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ This action parses the Skyhook configuration file and extracts service-specific | `working_directory` | Path to the repository root containing `.skyhook/skyhook.yaml` | No | `.` | | `service_name` | Name of the service to look up in the config | Yes | - | | `config_path` | Path to the skyhook config file relative to working_directory | No | `.skyhook/skyhook.yaml` | +| `default_dockerfile_path` | Fallback for `dockerfile_path` when the YAML doesn't supply one (config unreadable, service missing, or `buildTool.docker.dockerfilePath` unset/empty). | No | `""` | +| `default_build_context` | Fallback for `build_context` when the YAML doesn't supply one. | No | `""` | ## Outputs @@ -105,21 +107,34 @@ jobs: ## Behavior matrix -| Scenario | `config_found` | `service_found` | `build_context` | Other outputs | +The two build-tool outputs (`build_context`, `dockerfile_path`) follow a single rule: + +> **YAML wins when the config is readable AND the service exists AND the field is non-empty. In every other case, the caller-supplied `default_*` input is emitted.** + +`name`, `path`, `deployment_repo`, and `deployment_repo_path` are always sourced from the YAML and emit empty when the config/service can't be read — there are no `default_*` fallbacks for them. + +| Scenario | `config_found` | `service_found` | `build_context` | `dockerfile_path` | |---|---|---|---|---| -| Config file missing | `false` | `false` | `""` | `""` | -| Config found, service missing | `true` | `false` | `""` | `""` | -| Service found, `buildContext` set | `true` | `true` | from config | from config | -| Service found, `buildContext` absent | `true` | `true` | `"."` | from config | -| Duplicate service names in config | n/a | n/a | n/a | action exits 1 | +| Config file missing | `false` | `false` | `default_build_context` | `default_dockerfile_path` | +| Config found, service missing | `true` | `false` | `default_build_context` | `default_dockerfile_path` | +| Service found, both fields set | `true` | `true` | from YAML | from YAML | +| Service found, only `buildContext` set | `true` | `true` | from YAML | `default_dockerfile_path` | +| Service found, neither field set | `true` | `true` | `default_build_context` | `default_dockerfile_path` | +| Duplicate service names | n/a | n/a | n/a | action exits 1 | -`build_context` defaults to `"."` only when the service is found and the field is absent. When the service or config itself is missing, `build_context` is empty - the workflow should decide whether to fall back or fail loudly: +Without `default_*` inputs the fallback is empty — meaning a workflow that wants a guaranteed-non-empty value should pass them at the call site: ```yaml -context: ${{ steps.config.outputs.build_context || '.' }} -dockerfile: ${{ steps.config.outputs.dockerfile_path || 'Dockerfile' }} +- uses: skyhook-io/read-config@v1 + with: + service_name: my-svc + # Computed sensible fallbacks the caller controls: + default_dockerfile_path: services/my-svc/Dockerfile + default_build_context: . ``` +`config_found` and `service_found` remain available so the caller can distinguish between a YAML-sourced value and a fallback if needed. + ## Runner requirements - Bash + `yq` v4.x. The action installs yq v4.47.1 if missing. diff --git a/action.yml b/action.yml index 173e9fb..0ae0199 100644 --- a/action.yml +++ b/action.yml @@ -14,6 +14,14 @@ inputs: description: 'Path to the skyhook config file relative to working_directory' required: false default: '.skyhook/skyhook.yaml' + default_dockerfile_path: + description: 'Fallback for the dockerfile_path output. Used when the YAML config is unreadable, the service is missing, or buildTool.docker.dockerfilePath is unset/empty. Empty by default — the caller decides whether to fall back ("Dockerfile", "/Dockerfile", etc.).' + required: false + default: '' + default_build_context: + description: 'Fallback for the build_context output. Used when the YAML config is unreadable, the service is missing, or buildTool.docker.buildContext is unset/empty. Empty by default — the caller decides (".", the source path, etc.).' + required: false + default: '' outputs: # Service configuration @@ -32,10 +40,10 @@ outputs: # Build tool configuration build_context: - description: 'Docker build context relative to repo root (defaults to "." when absent in config)' + description: 'Docker build context relative to repo root. Sourced from buildTool.docker.buildContext in the YAML when available; falls back to the default_build_context input otherwise.' value: ${{ steps.parse.outputs.build_context }} dockerfile_path: - description: 'Dockerfile path relative to repo root' + description: 'Dockerfile path relative to repo root. Sourced from buildTool.docker.dockerfilePath in the YAML when available; falls back to the default_dockerfile_path input otherwise.' value: ${{ steps.parse.outputs.dockerfile_path }} # Status @@ -110,6 +118,8 @@ runs: WORKING_DIR: ${{ inputs.working_directory }} SERVICE_NAME: ${{ inputs.service_name }} CONFIG_PATH: ${{ inputs.config_path }} + DEFAULT_DOCKERFILE_PATH: ${{ inputs.default_dockerfile_path }} + DEFAULT_BUILD_CONTEXT: ${{ inputs.default_build_context }} run: bash "$GITHUB_ACTION_PATH/scripts/parse.sh" branding: diff --git a/scripts/parse.sh b/scripts/parse.sh index 812eb4c..f8a0ad6 100755 --- a/scripts/parse.sh +++ b/scripts/parse.sh @@ -2,10 +2,18 @@ # Parse a Skyhook config file and emit GitHub Actions-style outputs. # # Inputs (env): -# WORKING_DIR - repo root containing the config (default ".") -# SERVICE_NAME - service to look up (required, non-empty) -# CONFIG_PATH - path to config relative to WORKING_DIR (default ".skyhook/skyhook.yaml") -# GITHUB_OUTPUT - file to append outputs to (required by GitHub Actions; tests pass a tempfile) +# WORKING_DIR - repo root containing the config (default ".") +# SERVICE_NAME - service to look up (required, non-empty) +# CONFIG_PATH - path to config relative to WORKING_DIR (default ".skyhook/skyhook.yaml") +# DEFAULT_DOCKERFILE_PATH - fallback when YAML doesn't supply dockerfile_path +# DEFAULT_BUILD_CONTEXT - fallback when YAML doesn't supply build_context +# GITHUB_OUTPUT - file to append outputs to (required by GitHub Actions; tests pass a tempfile) +# +# Fallback rule for build_context / dockerfile_path: the YAML value wins when +# the config is readable AND the service exists AND the field is non-empty. +# In every other case (config missing, service missing, field absent or +# explicitly null/empty) the corresponding DEFAULT_* input is emitted — +# empty by default, so the caller decides what their fallback should be. # # Exits non-zero with ::error:: on: # - empty SERVICE_NAME @@ -21,6 +29,8 @@ WORKING_DIR="${WORKING_DIR:-.}" WORKING_DIR="${WORKING_DIR%/}" CONFIG_PATH="${CONFIG_PATH:-.skyhook/skyhook.yaml}" SERVICE_NAME="${SERVICE_NAME:-}" +DEFAULT_DOCKERFILE_PATH="${DEFAULT_DOCKERFILE_PATH:-}" +DEFAULT_BUILD_CONTEXT="${DEFAULT_BUILD_CONTEXT:-}" if [ -z "$SERVICE_NAME" ]; then echo "::error::service_name input is required and must be non-empty" @@ -31,10 +41,16 @@ CONFIG_FILE="${WORKING_DIR}/${CONFIG_PATH}" # Heredoc-form output write: safe for multiline values, leading/trailing whitespace, # and values containing literal "=". Generates a unique delimiter per call. +# +# The earlier `tr -dc 'a-f0-9' > "$GITHUB_OUTPUT" } +# Emit empty/identity outputs for the no-data path. build_context and +# dockerfile_path still fall back to the caller-supplied defaults so a +# missing config / service yields the same shape as a present-but-empty one. write_empty_outputs() { write_output name "" write_output path "" write_output deployment_repo "" write_output deployment_repo_path "" - write_output build_context "" - write_output dockerfile_path "" + write_output build_context "$DEFAULT_BUILD_CONTEXT" + write_output dockerfile_path "$DEFAULT_DOCKERFILE_PATH" } # Config file missing @@ -114,8 +133,11 @@ DOCKERFILE_PATH=$(yq e "${SERVICE_PATH}.buildTool.docker.dockerfilePath // \"\"" [ "$BUILD_CONTEXT" = "null" ] && BUILD_CONTEXT="" [ "$DOCKERFILE_PATH" = "null" ] && DOCKERFILE_PATH="" -# Default build context to "." when absent -[ -z "$BUILD_CONTEXT" ] && BUILD_CONTEXT="." +# Apply caller-supplied fallbacks for the build-tool fields when YAML didn't +# provide a value. No hardcoded action-side defaults — the caller owns the +# fallback policy via the default_* inputs. +[ -z "$BUILD_CONTEXT" ] && BUILD_CONTEXT="$DEFAULT_BUILD_CONTEXT" +[ -z "$DOCKERFILE_PATH" ] && DOCKERFILE_PATH="$DEFAULT_DOCKERFILE_PATH" write_output name "$NAME" write_output path "$PATH_VALUE" diff --git a/tests/parse_test.sh b/tests/parse_test.sh index 6eb209d..ce67821 100755 --- a/tests/parse_test.sh +++ b/tests/parse_test.sh @@ -100,6 +100,24 @@ run_parse() { WORKING_DIR="$working_dir" \ SERVICE_NAME="$svc" \ CONFIG_PATH="$cfg_path" \ + DEFAULT_DOCKERFILE_PATH="${DEFAULT_DOCKERFILE_PATH:-}" \ + DEFAULT_BUILD_CONTEXT="${DEFAULT_BUILD_CONTEXT:-}" \ + GITHUB_OUTPUT="$out_file" \ + bash "$PARSE_SCRIPT" >/dev/null + echo "$out_file" +} + +# Run parse.sh with explicit caller-supplied default_* fallback inputs. +run_parse_with_defaults() { + local working_dir="$1" svc="$2" default_df="$3" default_ctx="$4" + local out_file + out_file="$WORK/gh_output.$RANDOM" + : >"$out_file" + WORKING_DIR="$working_dir" \ + SERVICE_NAME="$svc" \ + CONFIG_PATH=".skyhook/skyhook.yaml" \ + DEFAULT_DOCKERFILE_PATH="$default_df" \ + DEFAULT_BUILD_CONTEXT="$default_ctx" \ GITHUB_OUTPUT="$out_file" \ bash "$PARSE_SCRIPT" >/dev/null echo "$out_file" @@ -155,19 +173,53 @@ assert_eq "$(read_output "$out" deployment_repo_path)" "nbjkgj" "with-context: d assert_eq "$(read_output "$out" build_context)" "java-web-project/src" "with-context: build_context" assert_eq "$(read_output "$out" dockerfile_path)" "java-web-project/src/Dockerfile" "with-context: dockerfile_path" -# --- buildContext absent => default "." --- +# --- buildContext absent + no DEFAULT_BUILD_CONTEXT => empty --- +# The action no longer hardcodes "." — the caller owns the fallback via the +# default_build_context input. Without one supplied, the output is empty. out=$(run_parse "$WORK" no-context) -assert_eq "$(read_output "$out" build_context)" "." "no-context: build_context defaults to '.'" +assert_eq "$(read_output "$out" build_context)" "" "no-context: build_context empty when no default supplied" assert_eq "$(read_output "$out" dockerfile_path)" "java-multi-modules/Dockerfile" "no-context: dockerfile_path" assert_eq "$(read_output "$out" deployment_repo)" "skyhook-dev/deployment" "no-context: deployment_repo" -# --- buildContext: null => default "." --- +# --- buildContext: null + no DEFAULT_BUILD_CONTEXT => empty --- out=$(run_parse "$WORK" explicit-null) -assert_eq "$(read_output "$out" build_context)" "." "explicit-null: build_context defaults to '.'" +assert_eq "$(read_output "$out" build_context)" "" "explicit-null: build_context empty when no default supplied" -# --- buildContext: "" => default "." --- +# --- buildContext: "" + no DEFAULT_BUILD_CONTEXT => empty --- out=$(run_parse "$WORK" empty-string) -assert_eq "$(read_output "$out" build_context)" "." "empty-string: build_context defaults to '.'" +assert_eq "$(read_output "$out" build_context)" "" "empty-string: build_context empty when no default supplied" + +# --- DEFAULT_BUILD_CONTEXT fills in when YAML field is absent --- +out=$(run_parse_with_defaults "$WORK" no-context "" ".") +assert_eq "$(read_output "$out" build_context)" "." "no-context + default_build_context='.': output is '.'" + +out=$(run_parse_with_defaults "$WORK" explicit-null "" "java-multi-modules") +assert_eq "$(read_output "$out" build_context)" "java-multi-modules" "explicit-null + custom default: output is the default" + +out=$(run_parse_with_defaults "$WORK" empty-string "" "svc-empty") +assert_eq "$(read_output "$out" build_context)" "svc-empty" "empty-string + custom default: output is the default" + +# --- DEFAULT_BUILD_CONTEXT NOT used when YAML provides a value --- +out=$(run_parse_with_defaults "$WORK" with-context "" "should-not-be-used") +assert_eq "$(read_output "$out" build_context)" "java-web-project/src" "yaml value wins over default_build_context" + +# --- DEFAULT_DOCKERFILE_PATH fills in when YAML field is absent --- +# (Add a fixture without dockerfilePath to exercise this.) +NO_DF_DIR="$WORK/no-df" +mkdir -p "$NO_DF_DIR/.skyhook" +cat >"$NO_DF_DIR/.skyhook/skyhook.yaml" <<'YAML' +services: + - name: bare + path: services/bare +YAML +out=$(run_parse_with_defaults "$NO_DF_DIR" bare "services/bare/Dockerfile" "services/bare") +assert_eq "$(read_output "$out" dockerfile_path)" "services/bare/Dockerfile" "bare service + default_dockerfile_path: output is the default" +assert_eq "$(read_output "$out" build_context)" "services/bare" "bare service + default_build_context: output is the default" +assert_eq "$(read_output "$out" service_found)" "true" "bare service still reports service_found=true" + +# --- DEFAULT_DOCKERFILE_PATH NOT used when YAML provides a value --- +out=$(run_parse_with_defaults "$WORK" with-context "should-not-be-used" "") +assert_eq "$(read_output "$out" dockerfile_path)" "java-web-project/src/Dockerfile" "yaml value wins over default_dockerfile_path" # --- value with spaces survives heredoc round-trip --- out=$(run_parse "$WORK" with-spaces) @@ -179,17 +231,32 @@ out=$(run_parse "$WORK" 'name-with-"-quote') assert_eq "$(read_output "$out" service_found)" "true" "quoted service name resolves" assert_eq "$(read_output "$out" path)" "svc-quote" "quoted service name: path" -# --- service not found --- +# --- service not found, no defaults => empty --- out=$(run_parse "$WORK" nonexistent-service) assert_eq "$(read_output "$out" config_found)" "true" "missing service: config_found=true" assert_eq "$(read_output "$out" service_found)" "false" "missing service: service_found=false" -assert_eq "$(read_output "$out" build_context)" "" "missing service: build_context empty (asymmetry)" +assert_eq "$(read_output "$out" build_context)" "" "missing service: build_context empty when no default" +assert_eq "$(read_output "$out" dockerfile_path)" "" "missing service: dockerfile_path empty when no default" + +# --- service not found, WITH defaults => defaults are emitted --- +out=$(run_parse_with_defaults "$WORK" nonexistent-service "Dockerfile" ".") +assert_eq "$(read_output "$out" config_found)" "true" "missing service + defaults: config_found=true" +assert_eq "$(read_output "$out" service_found)" "false" "missing service + defaults: service_found=false" +assert_eq "$(read_output "$out" build_context)" "." "missing service + defaults: build_context fallback applied" +assert_eq "$(read_output "$out" dockerfile_path)" "Dockerfile" "missing service + defaults: dockerfile_path fallback applied" -# --- config file missing --- +# --- config file missing, no defaults => empty --- out=$(run_parse "$WORK/no-such-dir" any-service) assert_eq "$(read_output "$out" config_found)" "false" "missing config: config_found=false" assert_eq "$(read_output "$out" service_found)" "false" "missing config: service_found=false" -assert_eq "$(read_output "$out" build_context)" "" "missing config: build_context empty" +assert_eq "$(read_output "$out" build_context)" "" "missing config: build_context empty when no default" +assert_eq "$(read_output "$out" dockerfile_path)" "" "missing config: dockerfile_path empty when no default" + +# --- config file missing, WITH defaults => defaults are emitted --- +out=$(run_parse_with_defaults "$WORK/no-such-dir" any-service "fallback/Dockerfile" "fallback") +assert_eq "$(read_output "$out" config_found)" "false" "missing config + defaults: config_found=false" +assert_eq "$(read_output "$out" build_context)" "fallback" "missing config + defaults: build_context fallback applied" +assert_eq "$(read_output "$out" dockerfile_path)" "fallback/Dockerfile" "missing config + defaults: dockerfile_path fallback applied" # --- empty service_name --- err=$(run_parse_expect_fail "$WORK" "") @@ -271,5 +338,13 @@ grep -q "strenv(SERVICE_NAME)" "$PARSE_SCRIPT" || { echo "FAIL: parse.sh does no grep -q "Multiple services named" "$PARSE_SCRIPT" || { echo "FAIL: parse.sh does not detect duplicate names"; exit 1; } grep -q "service_name input is required" "$PARSE_SCRIPT" || { echo "FAIL: parse.sh does not validate non-empty service_name"; exit 1; } grep -q 'Failed to parse' "$PARSE_SCRIPT" || { echo "FAIL: parse.sh does not surface yq parse errors"; exit 1; } -grep -q 'BUILD_CONTEXT="\."' "$PARSE_SCRIPT" || { echo "FAIL: parse.sh does not default build_context to '.'"; exit 1; } -echo "PASS: parse.sh has strenv, dup-detection, empty-name validation, parse-error surfacing, build_context default" +grep -q 'DEFAULT_BUILD_CONTEXT' "$PARSE_SCRIPT" || { echo "FAIL: parse.sh does not consume DEFAULT_BUILD_CONTEXT"; exit 1; } +grep -q 'DEFAULT_DOCKERFILE_PATH' "$PARSE_SCRIPT" || { echo "FAIL: parse.sh does not consume DEFAULT_DOCKERFILE_PATH"; exit 1; } +echo "PASS: parse.sh has strenv, dup-detection, empty-name validation, parse-error surfacing, caller-driven defaults" + +# --- action.yml exposes the new default_* inputs --- +grep -q 'default_dockerfile_path:' "$ACTION_FILE" || { echo "FAIL: action.yml does not declare default_dockerfile_path input"; exit 1; } +grep -q 'default_build_context:' "$ACTION_FILE" || { echo "FAIL: action.yml does not declare default_build_context input"; exit 1; } +grep -q 'DEFAULT_DOCKERFILE_PATH: ${{ inputs.default_dockerfile_path }}' "$ACTION_FILE" || { echo "FAIL: action.yml does not pipe default_dockerfile_path to parse.sh"; exit 1; } +grep -q 'DEFAULT_BUILD_CONTEXT: ${{ inputs.default_build_context }}' "$ACTION_FILE" || { echo "FAIL: action.yml does not pipe default_build_context to parse.sh"; exit 1; } +echo "PASS: action.yml exposes default_dockerfile_path + default_build_context inputs piped to parse.sh"