Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
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 }}
Comment thread
cursor[bot] marked this conversation as resolved.
55 changes: 51 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
shell-tests:
strategy:
fail-fast: false
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

Expand All @@ -34,6 +42,7 @@ jobs:

smoke:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

Expand All @@ -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"
10 changes: 10 additions & 0 deletions .releaserc.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
33 changes: 24 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<service-path>/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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
42 changes: 32 additions & 10 deletions scripts/parse.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -31,24 +41,33 @@ 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' </dev/urandom | head -c 16` form hung
# indefinitely on macOS runners: when `head` closes its stdin after reading
# 16 bytes, `tr` reading from `/dev/urandom` did not always receive SIGPIPE
# in time and would block, killing the whole job. Use bash's built-in
# $RANDOM + $$ for collision-free uniqueness across a few outputs - no
# external process, no pipe SIGPIPE dance, fully portable.
write_output() {
local key="$1" val="$2"
local delim
delim="ghadelim_$(LC_ALL=C tr -dc 'a-f0-9' </dev/urandom 2>/dev/null | head -c 16 || echo "$$_$RANDOM$RANDOM")"
local delim="ghadelim_${$}_${RANDOM}${RANDOM}${RANDOM}${RANDOM}"
{
printf '%s<<%s\n' "$key" "$delim"
printf '%s\n' "$val"
printf '%s\n' "$delim"
} >> "$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
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading