From 01f086bbac2c74af37d3dbb535f3084902bd61cb Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 27 May 2026 17:33:14 +0300 Subject: [PATCH 1/8] Integrate rabbit lifecycle resolution --- .github/dependabot.yml | 6 ++ .rabbit/context.yaml | 83 +++++++++++++++++++ README.md | 25 ++++-- action.yml | 88 +++++++++++++-------- bin/lib/config.sh | 7 +- bin/lib/lifecycle.sh | 65 +++++++-------- bin/merge-configs.sh | 7 ++ tests/run-merge-tests.sh | 167 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 375 insertions(+), 73 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .rabbit/context.yaml create mode 100755 tests/run-merge-tests.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c75e875 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.rabbit/context.yaml b/.rabbit/context.yaml new file mode 100644 index 0000000..b7613ef --- /dev/null +++ b/.rabbit/context.yaml @@ -0,0 +1,83 @@ +# Generated by dev.kit repo — do not edit manually. +# Run `dev.kit repo` to refresh. +kind: repoContext +version: udx.dev/dev.kit/v1 +generator: + tool: dev.kit + repo: https://github.com/udx/dev.kit + version: 0.12.0 + generated_at: 2026-05-27T14:31:15Z + sources: + homepage: https://udx.dev/kit + repository: https://github.com/udx/dev.kit + package: https://www.npmjs.com/package/@udx/dev-kit + installation: https://github.com/udx/dev.kit/blob/latest/docs/installation.md + +repo: + name: github-rabbit-action + archetype: manifest-repo + +# Refs — Direct-read files and paths that define the repo contract. +# Note: Include only files or directories a repo consumer should read before code exploration. +# Note: Prefer README, focused docs, workflows, manifests, and explicit operational files. +# Note: Exclude broad implementation directories unless they are the contract themselves. + +refs: + - ./README.md + - ./src/configs/lifecycle-policy.yaml + - ./action.yml + - ./.github/workflows + - ./docs + +# Gaps — Factors that are missing or only partially supported by current repo signals. +# Note: Base the result on explicit factor rules, not free-form judgment. +# Note: Include message and evidence so the status can be reviewed. +# Note: Prefer traceable refs and missing signals over vague advice. + +gaps: + - factor: config + status: missing + message: No repo-owned configuration contract was found in docs, manifests, or checked-in example files. + repair_target: README.md or .env.example + reference: README.md + evidence: + - none + - factor: pipeline + status: partial + message: Found partial pipeline signals in tests, but the repo does not declare a complete validation/deploy contract yet. + repair_target: action.yml + reference: action.yml + evidence: + - tests + +# Dependencies — Meaningful dependency-repo contracts such as reusable workflows, images, or versioned manifests this repo relies on. +# Note: Capture execution-shaping behavior defined outside the current checkout. +# Note: Avoid promoting standard package inventory or ordinary GitHub action refs into top-level context. +# Note: Normalize same-org versioned refs into repo slugs when possible. + +dependencies: + - repo: udx/rabbit-infra-config + kind: manifest contract (v1) + resolved: true + declared_as: udx.dev/rabbit-infra-config/v1 + archetype: Composite action for determine infra config files and merge them. + used_by: + - src/configs/lifecycle-policy.yaml + +# Manifests — YAML files that define repo-specific workflow, deploy, or contract behavior. +# Note: Include custom config/manifests that materially shape repo behavior or contract understanding. +# Note: Do not include workflow YAML only because it lives under .github/workflows. +# Note: Promote workflow files only when they declare reusable workflow refs or other repo-specific execution contracts. +# Note: Prefer structured kind and description metadata from the manifest itself. +# Note: Include hidden or nested contract dirs when they contain repo-owned manifests with meaningful metadata. + +manifests: + - path: src/configs/lifecycle-policy.yaml + kind: lifecyclePolicy + declared_as: udx.dev/rabbit-infra-config/v1 + source_repo: udx/rabbit-infra-config + used_by: + - bin/lib/config.sh + evidence: + - version: udx.dev/rabbit-infra-config/v1 + - path reference: bin/lib/config.sh diff --git a/README.md b/README.md index 98ef1f3..433ba4d 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,16 @@ services: │ GitHub Action Trigger (push / PR / delete / manual) │ └──────────────────────┬──────────────────────────────┘ │ - ┌─────────────▼──────────────┐ - │ 1. Merge Configs │ - │ Discover .rabbit/ YAML │ - │ Resolve lifecycle │ - │ Deep merge by module::id │ + ┌─────────────▼──────────────┐ + │ 1. Resolve Lifecycle │ + │ Branch/env policy via │ + │ udx/rabbit-lifecycle │ + └─────────────┬──────────────┘ + │ + ┌─────────────▼──────────────┐ + │ 2. Merge Configs │ + │ Discover .rabbit/ YAML │ + │ Deep merge by module::id │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ @@ -170,7 +175,7 @@ services: ### Environment Detection -The environment is automatically resolved from: +The environment is automatically resolved from the workflow event, then passed to `udx/rabbit-lifecycle` for lifecycle policy resolution: | Trigger | Environment Source | | --- | --- | @@ -201,7 +206,8 @@ Infrastructure configs live in `.rabbit/` directories organized by lifecycle: - Files are sorted by name (`10-infra.yaml` before `20-monitoring.yaml`) - Services with the same `module::id` are deep-merged across files -- Root-level files in `.rabbit/` are ignored (must be in a lifecycle directory) +- Root-level files in the configured `source_dir` are ignored (must be in a lifecycle directory) +- Only direct lifecycle roots under `source_dir` are eligible; use `source_dir: .rabbit/infra_configs` for nested config roots ### Plan Mode @@ -473,7 +479,7 @@ The workflow dispatch inputs provide safe manual control: | `newrelic_api_key` | — | — | New Relic API key | | `slack_webhook` | — | — | Slack webhook URL | | `source_dir` | — | `.rabbit` | Config source directory | -| `github_token` | — | `github.token` | GitHub token for PR comments | +| `github_token` | — | `github.token` | GitHub token for lifecycle branch protection checks and PR comments | ## Outputs @@ -481,6 +487,8 @@ The workflow dispatch inputs provide safe manual control: | --- | --- | | `environment` | Resolved environment name | | `lifecycle` | Resolved lifecycle (production/staging/development) | +| `is_protected` | Whether GitHub reported the environment branch as protected | +| `resolution_reason` | Lifecycle rule that selected the lifecycle | | `plan_only` | Whether run was plan-only | | `terraform_action` | Action executed (apply/destroy/skip) | | `has_changes` | Whether Terraform detected changes | @@ -528,6 +536,7 @@ Notifications include environment, change counts, failure stage, and a link to t - **Pin `r2a_version`** to a specific tag for reproducible deploys (e.g., `4.8.0` instead of `latest`) - **Name files with numeric prefixes** (`10-dns.yaml`, `20-cdn.yaml`, `30-app.yaml`) for deterministic ordering - **Use `#{Environment}` placeholders** in service IDs to keep configs environment-aware +- **Set `source_dir` explicitly** when configs live below `.rabbit/infra_configs` or another nested root - **Schedule nightly runs** (`cron: "0 2 * * *"`) to detect infrastructure drift - **Keep `.rabbit/` configs small and focused** — one concern per file diff --git a/action.yml b/action.yml index 8301afc..8dbd722 100644 --- a/action.yml +++ b/action.yml @@ -98,7 +98,13 @@ outputs: value: ${{ steps.resolve-env.outputs.environment }} lifecycle: description: "Resolved lifecycle (production, staging, development)" - value: ${{ steps.merge-configs.outputs.lifecycle }} + value: ${{ steps.resolve-env.outputs.lifecycle }} + is_protected: + description: "Whether GitHub reported the resolved environment branch as protected" + value: ${{ steps.resolve-lifecycle.outputs.is_protected }} + resolution_reason: + description: "Lifecycle rule that selected the lifecycle" + value: ${{ steps.resolve-lifecycle.outputs.resolution_reason }} plan_only: description: "Whether the run was plan-only" value: ${{ steps.plan-mode.outputs.plan_only }} @@ -125,28 +131,46 @@ runs: using: "composite" steps: # ========================================================================= - # 1. Install yq + # 1. Resolve Rabbit lifecycle # ========================================================================= - - name: Install yq + - name: Resolve Rabbit lifecycle + id: resolve-lifecycle + uses: udx/rabbit-lifecycle@lifecycle-action-contract + with: + source_dir: ${{ inputs.source_dir }} + env_name: ${{ inputs.environment || (github.event_name == 'pull_request' && github.base_ref) || (github.event_name == 'delete' && github.event.ref) || github.ref_name }} + github_token: ${{ inputs.github_token }} + + # ========================================================================= + # 2. Install pinned yq + # ========================================================================= + - name: Install pinned yq shell: bash run: | set -euo pipefail - if ! command -v yq >/dev/null 2>&1; then - echo "Installing yq..." - curl -sSL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" -o /usr/local/bin/yq - chmod +x /usr/local/bin/yq - fi + version="v4.44.3" + install_dir="${RUNNER_TEMP:-/tmp}/rabbit-action-bin" + mkdir -p "$install_dir" + + url="https://github.com/mikefarah/yq/releases/download/${version}/yq_linux_amd64" + curl -fsSL "$url" -o "$install_dir/yq" + chmod +x "$install_dir/yq" + echo "$install_dir" >> "$GITHUB_PATH" + export PATH="$install_dir:$PATH" yq --version # ========================================================================= - # 2. Merge infrastructure configs + # 3. Merge infrastructure configs # ========================================================================= - name: Merge infrastructure configs id: merge-configs shell: bash env: INPUT_SOURCE_DIR: ${{ inputs.source_dir }} - INPUT_ENV_NAME: ${{ inputs.environment || (github.event_name == 'pull_request' && github.base_ref) || (github.event_name == 'delete' && github.event.ref) || github.ref_name }} + INPUT_ENV_NAME: ${{ steps.resolve-lifecycle.outputs.environment }} + INPUT_LIFECYCLE: ${{ steps.resolve-lifecycle.outputs.lifecycle }} + INPUT_IS_PROTECTED: ${{ steps.resolve-lifecycle.outputs.is_protected }} + INPUT_RESOLUTION_REASON: ${{ steps.resolve-lifecycle.outputs.resolution_reason }} GITHUB_TOKEN: ${{ inputs.github_token }} INPUT_EXCLUDE: "**/merged*.yml,**/merged*.yaml" INPUT_RECURSIVE: "true" @@ -160,7 +184,7 @@ runs: bash "${GITHUB_ACTION_PATH}/bin/merge-configs.sh" # ========================================================================= - # 3. Resolve environment + # 4. Resolve environment # ========================================================================= - name: Resolve environment id: resolve-env @@ -169,13 +193,15 @@ runs: MERGED_ENV: ${{ steps.merge-configs.outputs.environment }} MERGED_LIFECYCLE: ${{ steps.merge-configs.outputs.lifecycle }} MERGED_CONFIG_PATH: ${{ steps.merge-configs.outputs.merged_config }} + RESOLVED_ENV: ${{ steps.resolve-lifecycle.outputs.environment }} + RESOLVED_LIFECYCLE: ${{ steps.resolve-lifecycle.outputs.lifecycle }} EVENT_NAME: ${{ github.event_name }} EVENT_REF_TYPE: ${{ github.event.ref_type }} REQUESTED_ENV: ${{ inputs.environment || (github.event_name == 'pull_request' && github.base_ref) || (github.event_name == 'delete' && github.event.ref) || github.ref_name }} run: | set -euo pipefail - environment="$MERGED_ENV" - lifecycle="$MERGED_LIFECYCLE" + environment="${RESOLVED_ENV:-$MERGED_ENV}" + lifecycle="${RESOLVED_LIFECYCLE:-$MERGED_LIFECYCLE}" if [[ "$EVENT_NAME" == "delete" && "$EVENT_REF_TYPE" == "branch" && -n "$REQUESTED_ENV" && "$environment" != "$REQUESTED_ENV" ]]; then echo "::error title=❌ Environment Resolution Mismatch::Delete requested '$REQUESTED_ENV' but resolved '$environment'. Aborting to prevent wrong-environment destroy." @@ -191,7 +217,7 @@ runs: echo "Config path: $MERGED_CONFIG_PATH" # ========================================================================= - # 4. Determine plan mode + # 5. Determine plan mode # ========================================================================= - name: Determine plan mode id: plan-mode @@ -223,7 +249,7 @@ runs: fi # ========================================================================= - # 5. Determine terraform action + # 6. Determine terraform action # ========================================================================= - name: Determine terraform action id: terraform-action @@ -253,7 +279,7 @@ runs: echo "Terraform action: $terraform_action" # ========================================================================= - # 6. Safety checks + # 7. Safety checks # ========================================================================= - name: Safety checks id: safety @@ -289,7 +315,7 @@ runs: echo "should_deploy=true" >> "$GITHUB_OUTPUT" # ========================================================================= - # 7. Resolve deployment metadata + # 8. Resolve deployment metadata # ========================================================================= - name: Resolve deployment metadata id: deployment-metadata @@ -305,7 +331,7 @@ runs: echo "K8s namespace: $namespace" # ========================================================================= - # 8. Detect config services for summary + # 9. Detect config services for summary # ========================================================================= - name: Detect configured services id: detect-services @@ -345,7 +371,7 @@ runs: echo "detected_configs=$detected_configs" >> "$GITHUB_OUTPUT" # ========================================================================= - # 9. Write configuration summary + # 10. Write configuration summary # ========================================================================= - name: Write configuration summary if: always() @@ -401,7 +427,7 @@ runs: } >> "$GITHUB_STEP_SUMMARY" # ========================================================================= - # 10. Authenticate with Google Cloud + # 11. Authenticate with Google Cloud # ========================================================================= - name: Authenticate with Google Cloud id: gcp-auth @@ -412,7 +438,7 @@ runs: service_account: ${{ inputs.gcp_service_account }} # ========================================================================= - # 11. Configure AWS credentials (optional) + # 12. Configure AWS credentials (optional) # ========================================================================= - name: Configure AWS credentials if: steps.safety.outputs.should_deploy == 'true' && inputs.aws_role_arn != '' && inputs.aws_region != '' @@ -422,7 +448,7 @@ runs: aws-region: ${{ inputs.aws_region }} # ========================================================================= - # 12. Prepare artifacts directory + # 13. Prepare artifacts directory # ========================================================================= - name: Prepare artifacts directory if: steps.safety.outputs.should_deploy == 'true' @@ -432,7 +458,7 @@ runs: chmod -R 777 terraform/plans # ========================================================================= - # 13. Prepare GCP credentials for Docker volume mount + # 14. Prepare GCP credentials for Docker volume mount # ========================================================================= - name: Prepare Workload Identity credentials id: prepare-creds @@ -447,7 +473,7 @@ runs: echo "credentials_path=$(pwd)/gcp-credentials.json" >> "$GITHUB_OUTPUT" # ========================================================================= - # 14. Docker login and pull R2A image + # 15. Docker login and pull R2A image # ========================================================================= - name: Docker login and pull R2A image if: steps.safety.outputs.should_deploy == 'true' @@ -470,7 +496,7 @@ runs: echo "✓ Image pulled successfully" # ========================================================================= - # 15. Run Rabbit Automation Action (Terraform engine) + # 16. Run Rabbit Automation Action (Terraform engine) # ========================================================================= - name: Run Rabbit Automation Action id: terraform @@ -564,7 +590,7 @@ runs: echo "aws_cloudfront_distribution_id=${cloudfront_distribution_id}" >> "$GITHUB_OUTPUT" # ========================================================================= - # 16. Upload terraform artifacts + # 17. Upload terraform artifacts # ========================================================================= - name: Upload terraform artifacts if: steps.safety.outputs.should_deploy == 'true' @@ -576,7 +602,7 @@ runs: if-no-files-found: warn # ========================================================================= - # 17. Render plan summary + # 18. Render plan summary # ========================================================================= - name: Render plan summary id: plan-summary @@ -625,7 +651,7 @@ runs: bash "${GITHUB_ACTION_PATH}/bin/render-plan-summary.sh" "$summary_file" "$ARTIFACTS_PATH/plan-summary.md" # ========================================================================= - # 18. Write deployment summary + # 19. Write deployment summary # ========================================================================= - name: Write deployment summary if: always() && steps.safety.outputs.should_deploy == 'true' @@ -669,7 +695,7 @@ runs: fi # ========================================================================= - # 19. Post PR comment with plan summary + # 20. Post PR comment with plan summary # ========================================================================= - name: Post PR comment with plan summary if: github.event_name == 'pull_request' && steps.plan-summary.outputs.has_changes == 'true' @@ -730,7 +756,7 @@ runs: } # ========================================================================= - # 20. Upload terraform plan files + # 21. Upload terraform plan files # ========================================================================= - name: Upload terraform plans if: steps.plan-summary.outputs.has_changes == 'true' @@ -742,7 +768,7 @@ runs: if-no-files-found: ignore # ========================================================================= - # 21. Send Slack notification + # 22. Send Slack notification # ========================================================================= - name: Send Slack notification if: always() && inputs.slack_webhook != '' && (steps.plan-summary.outputs.has_changes == 'true' || steps.terraform.outcome == 'failure' || steps.safety.outcome == 'failure') diff --git a/bin/lib/config.sh b/bin/lib/config.sh index 0bc06d8..804d697 100755 --- a/bin/lib/config.sh +++ b/bin/lib/config.sh @@ -80,8 +80,11 @@ export SUBDIR_PREFERRED_LIFECYCLE="${INPUT_SUBDIR_PREFERRED_LIFECYCLE:-$DEFAULT_ export PROTECTED_BRANCH_LIFECYCLE="${INPUT_PROTECTED_BRANCH_LIFECYCLE:-$DEFAULT_PROTECTED_BRANCH_LIFECYCLE}" export FALLBACK_LIFECYCLE="${INPUT_FALLBACK_LIFECYCLE:-$DEFAULT_FALLBACK_LIFECYCLE}" -# Exported lifecycle (set in main function) -export LIFECYCLE="" +# Exported lifecycle metadata. INPUT_LIFECYCLE is supplied by udx/rabbit-lifecycle +# in the composite action path; local script runs can still resolve internally. +export LIFECYCLE="${INPUT_LIFECYCLE:-}" +export IS_PROTECTED="${INPUT_IS_PROTECTED:-}" +export RESOLUTION_REASON="${INPUT_RESOLUTION_REASON:-}" # ============================================================================ # INPUT PARAMETERS diff --git a/bin/lib/lifecycle.sh b/bin/lib/lifecycle.sh index fc3ae46..97f82e9 100755 --- a/bin/lib/lifecycle.sh +++ b/bin/lib/lifecycle.sh @@ -9,7 +9,6 @@ fi # Source dependencies LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$LIB_DIR/logging.sh" -source "$LIB_DIR/github.sh" source "$LIB_DIR/discovery.sh" # Note: Configuration is set in index.sh and passed via environment variables: @@ -22,8 +21,8 @@ source "$LIB_DIR/discovery.sh" # # Lifecycle Rules (applied throughout the codebase): # - Only configured subdirectory lifecycles support lifecycle// smart merge -# - Protected branches map to configured protected lifecycle -# - Unmatched environments map to configured fallback lifecycle +# - Pre-resolved lifecycle metadata comes from udx/rabbit-lifecycle in the action path +# - Local fallback does not inspect GitHub branch protection # Build lifecycle arrays from environment variables IFS=',' read -ra STABLE_LIFECYCLES <<< "${STABLE_LIFECYCLES_STR}" @@ -101,14 +100,8 @@ _find_env_directory() { echo "$result" } -# PRIVATE: Determine lifecycle for environment -# Returns: lifecycle name (production/staging/development) -# Resolution order: -# 1. Check if env name matches configured explicit lifecycle names -# 2. Check if configured subdir-preferred lifecycle has env subdirectory -# 3. Check branch protection: -# - Protected → configured protected lifecycle -# - Not protected → configured fallback lifecycle +# PRIVATE: Minimal local fallback for direct script usage. +# The composite action passes INPUT_LIFECYCLE from udx/rabbit-lifecycle. _determine_lifecycle_for_env() { local env_name="$1" shift @@ -130,15 +123,9 @@ _determine_lifecycle_for_env() { echo "$SUBDIR_PREFERRED_LIFECYCLE" return fi - - # Check branch protection - if github_check_branch_protection "$env_name"; then - dbg "Branch '$env_name' is protected → $PROTECTED_BRANCH_LIFECYCLE lifecycle" >&2 - echo "$PROTECTED_BRANCH_LIFECYCLE" - else - dbg "Branch '$env_name' not protected → $FALLBACK_LIFECYCLE lifecycle" >&2 - echo "$FALLBACK_LIFECYCLE" - fi + + dbg "No pre-resolved lifecycle for '$env_name'; using local fallback lifecycle '$FALLBACK_LIFECYCLE'" >&2 + echo "$FALLBACK_LIFECYCLE" } # PUBLIC: Get lifecycle and directory info for environment @@ -152,21 +139,28 @@ lifecycle_get_info() { shift local unique_dirs=("$@") - # Check if this is a protected branch (before determining lifecycle) - local is_protected="false" - if github_check_branch_protection "$env_name"; then - is_protected="true" + local lifecycle="${LIFECYCLE:-}" + local is_protected="${IS_PROTECTED:-false}" + local result + + if [[ -n "$lifecycle" ]]; then + dbg "Using pre-resolved lifecycle for '$env_name': $lifecycle" >&2 + result=$(_find_directory_for_lifecycle "$lifecycle" "$env_name" "${unique_dirs[@]}") + else + lifecycle=$(_determine_lifecycle_for_env "$env_name" "${unique_dirs[@]}") + dbg "Determined lifecycle for '$env_name': $lifecycle" >&2 + + # Find directory for this environment + result=$(_find_env_directory "$env_name" "${unique_dirs[@]}") fi - - # Determine lifecycle for this environment - local lifecycle=$(_determine_lifecycle_for_env "$env_name" "${unique_dirs[@]}") - dbg "Determined lifecycle for '$env_name': $lifecycle" >&2 - - # Find directory for this environment - local result=$(_find_env_directory "$env_name" "${unique_dirs[@]}") + local best_dir=$(echo "$result" | cut -d'|' -f1) local actual_lifecycle=$(echo "$result" | cut -d'|' -f2) - + + if [[ -z "$actual_lifecycle" ]]; then + actual_lifecycle="$lifecycle" + fi + echo "$actual_lifecycle|$best_dir|$is_protected" } @@ -184,8 +178,15 @@ lifecycle_detect_environments() { # Find all directories with YAML files while IFS= read -r dir; do + local relative_dir="${dir#$source_dir/}" + local lifecycle_root="${relative_dir%%/*}" local dir_name=$(basename "$dir") local parent_name=$(basename "$(dirname "$dir")") + + if [[ ",$ALL_LIFECYCLES_STR," != *",$lifecycle_root,"* ]]; then + dbg "Ignoring directory outside configured lifecycle roots: $dir" >&2 + continue + fi # Skip hidden/common directories if discovery_should_skip_directory "$dir_name"; then diff --git a/bin/merge-configs.sh b/bin/merge-configs.sh index 86b407d..780027e 100755 --- a/bin/merge-configs.sh +++ b/bin/merge-configs.sh @@ -42,8 +42,15 @@ main() { # Validate that files are in lifecycle directories (not root level) local valid_files=() for file in "${all_files[@]}"; do + local relative_file="${file#$SOURCE_DIR/}" + local lifecycle_root="${relative_file%%/*}" local file_dir=$(dirname "$file") local parent_dir=$(basename "$file_dir") + + if [[ ",$ALL_LIFECYCLES_STR," != *",$lifecycle_root,"* ]]; then + dbg "Skipping file outside configured lifecycle roots: $file" + continue + fi # Skip root-level files - configs must be in lifecycle directories if [[ "$parent_dir" == "$(basename "$SOURCE_DIR")" ]]; then diff --git a/tests/run-merge-tests.sh b/tests/run-merge-tests.sh new file mode 100755 index 0000000..db68e66 --- /dev/null +++ b/tests/run-merge-tests.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +PASSED=0 +FAILED=0 + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +write_yaml() { + local path="$1" + local body="$2" + + mkdir -p "$(dirname "$path")" + printf '%s\n' "$body" > "$path" +} + +read_output() { + local file="$1" + local key="$2" + + awk -F= -v key="$key" '$1 == key { value=substr($0, length(key) + 2) } END { print value }' "$file" +} + +run_merge() { + local source_dir="$1" + local env_name="$2" + local lifecycle="$3" + local output_file="$4" + + : > "$output_file" + INPUT_SOURCE_DIR="$source_dir" \ + INPUT_ENV_NAME="$env_name" \ + INPUT_LIFECYCLE="$lifecycle" \ + INPUT_IS_PROTECTED="false" \ + GITHUB_OUTPUT="$output_file" \ + "$PROJECT_ROOT/bin/merge-configs.sh" >/dev/null +} + +assert_eq() { + local actual="$1" + local expected="$2" + local name="$3" + + if [[ "$actual" == "$expected" ]]; then + echo " PASS $name" + PASSED=$((PASSED + 1)) + else + echo " FAIL $name - expected '$expected', got '$actual'" + FAILED=$((FAILED + 1)) + fi +} + +assert_file_exists() { + local path="$1" + local name="$2" + + if [[ -f "$path" ]]; then + echo " PASS $name" + PASSED=$((PASSED + 1)) + else + echo " FAIL $name - missing '$path'" + FAILED=$((FAILED + 1)) + fi +} + +scenario() { + echo "" + echo "$1" + echo "------------------------------------------------------------" +} + +ROOT="$TEMP_DIR/workspace" +SOURCE="$ROOT/.rabbit" +INFRA_SOURCE="$ROOT/.rabbit/infra_configs" + +write_yaml "$SOURCE/production/10-base.yaml" 'services: + - module: test-module + id: app + replicas: 2 + base: true' + +write_yaml "$SOURCE/production/main/20-override.yaml" 'services: + - module: test-module + id: app + replicas: 4 + override: true' + +write_yaml "$SOURCE/staging/10-stage.yaml" 'services: + - module: test-module + id: stage + replicas: 1' + +write_yaml "$SOURCE/staging/ignored/20-ignored.yaml" 'services: + - module: test-module + id: ignored + replicas: 99' + +write_yaml "$SOURCE/development/10-base.yaml" 'services: + - module: test-module + id: app + replicas: 1 + base: true' + +write_yaml "$SOURCE/development/dev-alice/20-override.yaml" 'services: + - module: test-module + id: app + replicas: 3 + developer: alice' + +write_yaml "$INFRA_SOURCE/production/10-base.yaml" 'services: + - module: test-module + id: infra + replicas: 2' + +write_yaml "$INFRA_SOURCE/production/main/20-override.yaml" 'services: + - module: test-module + id: infra + replicas: 5' + +echo "Rabbit config merge smoke tests" +echo "===============================" + +scenario "Scenario: production branch uses lifecycle base plus branch override" +production_out="$TEMP_DIR/production.out" +run_merge "$SOURCE" "main" "production" "$production_out" +production_config="$(read_output "$production_out" merged_config)" +assert_file_exists "$production_config" "Merged production config exists" +assert_eq "$(read_output "$production_out" environment)" "main" "Environment output is branch name" +assert_eq "$(read_output "$production_out" lifecycle)" "production" "Lifecycle output uses pre-resolved lifecycle" +assert_eq "$(yq -r '.services[0].replicas' "$production_config")" "4" "Production override wins" +assert_eq "$(yq -r '.services[0].base' "$production_config")" "true" "Production base field is retained" +assert_eq "$(yq -r '.services[0].override' "$production_config")" "true" "Production override field is retained" + +scenario "Scenario: staging lifecycle is root-only" +staging_out="$TEMP_DIR/staging.out" +run_merge "$SOURCE" "staging" "staging" "$staging_out" +staging_config="$(read_output "$staging_out" merged_config)" +assert_file_exists "$staging_config" "Merged staging config exists" +assert_eq "$(yq -r '.services | length' "$staging_config")" "1" "Staging ignores nested override directory" +assert_eq "$(yq -r '.services[0].id' "$staging_config")" "stage" "Staging uses root config" + +scenario "Scenario: development branch uses lifecycle base plus branch override" +development_out="$TEMP_DIR/development.out" +run_merge "$SOURCE" "dev-alice" "development" "$development_out" +development_config="$(read_output "$development_out" merged_config)" +assert_file_exists "$development_config" "Merged development config exists" +assert_eq "$(yq -r '.services[0].replicas' "$development_config")" "3" "Development override wins" +assert_eq "$(yq -r '.services[0].base' "$development_config")" "true" "Development base field is retained" +assert_eq "$(yq -r '.services[0].developer' "$development_config")" "alice" "Development override field is retained" + +scenario "Scenario: configurable source_dir supports infra_configs-style layouts" +infra_out="$TEMP_DIR/infra.out" +run_merge "$INFRA_SOURCE" "main" "production" "$infra_out" +infra_config="$(read_output "$infra_out" merged_config)" +assert_file_exists "$infra_config" "Merged infra_configs config exists" +assert_eq "$(yq -r '.services[0].replicas' "$infra_config")" "5" "infra_configs override wins" + +echo "" +echo "Results: $PASSED passed, $FAILED failed" + +if [[ $FAILED -gt 0 ]]; then + exit 1 +fi From 94c1ca67107de02923a930008da9ca2ddb1e0f03 Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 27 May 2026 17:39:10 +0300 Subject: [PATCH 2/8] Remove internal lifecycle protection policy --- .rabbit/context.yaml | 4 +- README.md | 2 +- action.yml | 3 +- bin/lib/config.sh | 6 +- bin/lib/environment.sh | 6 +- bin/lib/github.sh | 103 ------------------------------ bin/lib/lifecycle.sh | 1 - bin/lib/validation.sh | 49 +++----------- src/configs/lifecycle-policy.yaml | 4 +- 9 files changed, 18 insertions(+), 160 deletions(-) delete mode 100755 bin/lib/github.sh diff --git a/.rabbit/context.yaml b/.rabbit/context.yaml index b7613ef..870ba72 100644 --- a/.rabbit/context.yaml +++ b/.rabbit/context.yaml @@ -6,7 +6,7 @@ generator: tool: dev.kit repo: https://github.com/udx/dev.kit version: 0.12.0 - generated_at: 2026-05-27T14:31:15Z + generated_at: 2026-05-27T14:38:30Z sources: homepage: https://udx.dev/kit repository: https://github.com/udx/dev.kit @@ -73,7 +73,7 @@ dependencies: manifests: - path: src/configs/lifecycle-policy.yaml - kind: lifecyclePolicy + kind: rabbitConfigLayout declared_as: udx.dev/rabbit-infra-config/v1 source_repo: udx/rabbit-infra-config used_by: diff --git a/README.md b/README.md index 433ba4d..7209039 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ The workflow dispatch inputs provide safe manual control: | `newrelic_api_key` | — | — | New Relic API key | | `slack_webhook` | — | — | Slack webhook URL | | `source_dir` | — | `.rabbit` | Config source directory | -| `github_token` | — | `github.token` | GitHub token for lifecycle branch protection checks and PR comments | +| `github_token` | — | `github.token` | GitHub token passed to lifecycle resolution and used for PR comments | ## Outputs diff --git a/action.yml b/action.yml index 8dbd722..4c8c7af 100644 --- a/action.yml +++ b/action.yml @@ -88,7 +88,7 @@ inputs: required: false default: ".rabbit" github_token: - description: "GitHub token for branch protection detection and PR comments" + description: "GitHub token passed to lifecycle resolution and used for PR comments" required: false default: ${{ github.token }} @@ -171,7 +171,6 @@ runs: INPUT_LIFECYCLE: ${{ steps.resolve-lifecycle.outputs.lifecycle }} INPUT_IS_PROTECTED: ${{ steps.resolve-lifecycle.outputs.is_protected }} INPUT_RESOLUTION_REASON: ${{ steps.resolve-lifecycle.outputs.resolution_reason }} - GITHUB_TOKEN: ${{ inputs.github_token }} INPUT_EXCLUDE: "**/merged*.yml,**/merged*.yaml" INPUT_RECURSIVE: "true" INPUT_FILE_PATTERNS: "*.yml,*.yaml" diff --git a/bin/lib/config.sh b/bin/lib/config.sh index 804d697..30ed5ec 100755 --- a/bin/lib/config.sh +++ b/bin/lib/config.sh @@ -54,15 +54,14 @@ export NO_ENVIRONMENT_VALUE="${INPUT_NO_ENVIRONMENT_VALUE:-none}" # Discovery configuration (can be overridden via GitHub Action inputs) export SKIP_DIRECTORIES="${INPUT_SKIP_DIRECTORIES:-node_modules,vendor}" -# Lifecycle configuration (can be overridden via GitHub Action inputs) +# Rabbit config layout (can be overridden via GitHub Action inputs) DEFAULT_LIFECYCLE_PRODUCTION="production" DEFAULT_LIFECYCLE_STAGING="staging" DEFAULT_LIFECYCLE_DEVELOPMENT="development" DEFAULT_SUBDIRECTORY_LIFECYCLES="$(_yaml_csv_or_default '.config.lifecycles | to_entries | map(select(.value.allow_subdirs == true) | .key)' 'production,development')" DEFAULT_STABLE_LIFECYCLES="$(_yaml_csv_or_default '.config.lifecycles | to_entries | map(select(.value.allow_subdirs != true) | .key)' 'staging')" DEFAULT_ALL_LIFECYCLES="production,staging,development" -DEFAULT_PROTECTED_BRANCH_LIFECYCLE="$(_yaml_get_or_default '.config.lifecycles | to_entries | map(select(.value.protected_only == true) | .key) | .[0]' "$DEFAULT_LIFECYCLE_PRODUCTION")" -DEFAULT_FALLBACK_LIFECYCLE="$(_yaml_get_or_default '.config.lifecycles | to_entries | map(select(.value.is_fallback == true) | .key) | .[0]' "$DEFAULT_LIFECYCLE_DEVELOPMENT")" +DEFAULT_FALLBACK_LIFECYCLE="$DEFAULT_LIFECYCLE_DEVELOPMENT" DEFAULT_SUBDIR_PREFERRED_LIFECYCLE="${DEFAULT_FALLBACK_LIFECYCLE}" export LIFECYCLE_PRODUCTION="${INPUT_LIFECYCLE_PRODUCTION:-$DEFAULT_LIFECYCLE_PRODUCTION}" @@ -77,7 +76,6 @@ export SUBDIRECTORY_LIFECYCLES="${INPUT_SUBDIRECTORY_LIFECYCLES:-$DEFAULT_SUBDIR export STABLE_LIFECYCLES_STR="${INPUT_STABLE_LIFECYCLES:-$DEFAULT_STABLE_LIFECYCLES}" export ALL_LIFECYCLES_STR="${INPUT_ALL_LIFECYCLES:-$DEFAULT_ALL_LIFECYCLES}" export SUBDIR_PREFERRED_LIFECYCLE="${INPUT_SUBDIR_PREFERRED_LIFECYCLE:-$DEFAULT_SUBDIR_PREFERRED_LIFECYCLE}" -export PROTECTED_BRANCH_LIFECYCLE="${INPUT_PROTECTED_BRANCH_LIFECYCLE:-$DEFAULT_PROTECTED_BRANCH_LIFECYCLE}" export FALLBACK_LIFECYCLE="${INPUT_FALLBACK_LIFECYCLE:-$DEFAULT_FALLBACK_LIFECYCLE}" # Exported lifecycle metadata. INPUT_LIFECYCLE is supplied by udx/rabbit-lifecycle diff --git a/bin/lib/environment.sh b/bin/lib/environment.sh index 825ea0d..bd1387a 100755 --- a/bin/lib/environment.sh +++ b/bin/lib/environment.sh @@ -32,13 +32,13 @@ _should_include_file() { local file="$1" local best_dir="$2" local env_type="$3" - local is_fallback="$4" + local is_lifecycle_root="$4" # File must be in the selected directory [[ "$file" != "$best_dir"* ]] && return 1 - # Fallback or stable lifecycles: only direct files - if [[ "$is_fallback" == "true" ]] || ! _lifecycle_allows_subdirectories "$env_type"; then + # Lifecycle roots and root-only lifecycles use direct files only. + if [[ "$is_lifecycle_root" == "true" ]] || ! _lifecycle_allows_subdirectories "$env_type"; then _is_direct_file "$file" "$best_dir" && return 0 return 1 fi diff --git a/bin/lib/github.sh b/bin/lib/github.sh deleted file mode 100755 index 191c0d8..0000000 --- a/bin/lib/github.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -# GitHub API interaction utilities - -# Guard against multiple sourcing -if [[ "${GITHUB_LIB_LOADED:-}" == "true" ]]; then - return 0 -fi - -# Source dependencies -LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$LIB_DIR/logging.sh" - -# Constants -readonly GITHUB_API_BASE="https://api.github.com" -readonly GITHUB_API_VERSION="2022-11-28" - -# Simple protection check - no need for approval counting - -# PRIVATE: Make a GitHub API request -# Usage: _github_api_request "GET" "/repos/owner/repo/branches/main" "github_token" -# Returns: response body (stdout) and http code (via return value check) -_github_api_request() { - local method="$1" - local endpoint="$2" - local token="$3" - - local response - response=$(curl -s -w "\n%{http_code}" \ - -X "$method" \ - -H "Authorization: token $token" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: $GITHUB_API_VERSION" \ - "${GITHUB_API_BASE}${endpoint}" 2>/dev/null) - - local http_code=$(echo "$response" | tail -n1) - local body=$(echo "$response" | sed '$d') - - # Output body to stdout - echo "$body" - - # Return 0 for 2xx status codes, 1 otherwise - if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then - return 0 - else - return 1 - fi -} - -# PRIVATE: Get branch info to check protection status -# Usage: _get_branch_info "owner/repo" "branch-name" "token" -# Returns: 0 if successful, 1 otherwise -_get_branch_info() { - local repository="$1" - local branch="$2" - local token="$3" - - dbg "Fetching branch info for '$branch' in '$repository'" >&2 - - local response - response=$(_github_api_request "GET" "/repos/${repository}/branches/${branch}" "$token") - local status=$? - - if [[ $status -eq 0 ]]; then - echo "$response" - return 0 - fi - - return 1 -} - -# PUBLIC: Check if branch is protected via GitHub API -# Returns: 0 if protected, 1 if not protected -# Usage: github_check_branch_protection "branch-name" ["repository"] ["token"] -# - branch: Branch name to check (required) -# - repository: GitHub repository (optional, defaults to GITHUB_REPOSITORY env) -# - token: GitHub token (optional, defaults to GITHUB_TOKEN env) -github_check_branch_protection() { - local branch="$1" - local repository="${2:-${GITHUB_REPOSITORY:-}}" - local token="${3:-${GITHUB_TOKEN:-}}" - - # Skip if missing required parameters - if [[ -z "$branch" ]] || [[ -z "$token" ]] || [[ -z "$repository" ]]; then - dbg "Skipping branch protection check - missing parameters" >&2 - return 1 - fi - - dbg "Checking branch protection for '$branch' in '$repository'" >&2 - - # Get branch info and check protected field - local branch_info - if branch_info=$(_get_branch_info "$repository" "$branch" "$token"); then - if echo "$branch_info" | grep -q '"protected": *true'; then - dbg "Branch '$branch' is protected" >&2 - return 0 - fi - fi - - dbg "Branch '$branch' is not protected" >&2 - return 1 -} - -readonly GITHUB_LIB_LOADED="true" diff --git a/bin/lib/lifecycle.sh b/bin/lib/lifecycle.sh index 97f82e9..f8820a1 100755 --- a/bin/lib/lifecycle.sh +++ b/bin/lib/lifecycle.sh @@ -16,7 +16,6 @@ source "$LIB_DIR/discovery.sh" # - STABLE_LIFECYCLES_STR, ALL_LIFECYCLES_STR (comma-separated) # - SUBDIRECTORY_LIFECYCLES (comma-separated) # - SUBDIR_PREFERRED_LIFECYCLE -# - PROTECTED_BRANCH_LIFECYCLE # - FALLBACK_LIFECYCLE # # Lifecycle Rules (applied throughout the codebase): diff --git a/bin/lib/validation.sh b/bin/lib/validation.sh index 48402b3..01a1175 100755 --- a/bin/lib/validation.sh +++ b/bin/lib/validation.sh @@ -64,22 +64,9 @@ _require_non_empty() { return 0 } -_require_not_equal() { - local left="$1" - local right="$2" - local message="$3" - - if [[ "$left" == "$right" ]]; then - err "$message" - return 1 - fi - - return 0 -} - _validation_check_policy() { if [[ ! -f "$LIFECYCLE_POLICY_FILE" ]]; then - err "Lifecycle policy file not found: $LIFECYCLE_POLICY_FILE" + err "Rabbit config layout file not found: $LIFECYCLE_POLICY_FILE" return 1 fi @@ -88,30 +75,22 @@ _validation_check_policy() { return 1 fi - local kind version lifecycle_keys protected_count fallback_count protected_lifecycle fallback_lifecycle + local kind version lifecycle_keys mapfile -t policy_meta < <(_policy_eval ' .kind, .version, - (.config.lifecycles | keys | join(",")), - (.config.lifecycles | to_entries | map(select(.value.protected_only == true)) | length), - (.config.lifecycles | to_entries | map(select(.value.is_fallback == true)) | length), - (.config.lifecycles | to_entries | map(select(.value.protected_only == true) | .key) | .[0]), - (.config.lifecycles | to_entries | map(select(.value.is_fallback == true) | .key) | .[0]) + (.config.lifecycles | keys | join(",")) ') kind="${policy_meta[0]:-}" version="${policy_meta[1]:-}" lifecycle_keys="${policy_meta[2]:-}" - protected_count="${policy_meta[3]:-0}" - fallback_count="${policy_meta[4]:-0}" - protected_lifecycle="${policy_meta[5]:-}" - fallback_lifecycle="${policy_meta[6]:-}" - if ! _require_equal "$kind" "lifecyclePolicy" "Lifecycle policy kind must be 'lifecyclePolicy', got '$kind'"; then + if ! _require_equal "$kind" "rabbitConfigLayout" "Rabbit config layout kind must be 'rabbitConfigLayout', got '$kind'"; then return 1 fi - if ! _require_non_empty "$version" "Lifecycle policy must define a version"; then + if ! _require_non_empty "$version" "Rabbit config layout must define a version"; then return 1 fi @@ -119,21 +98,9 @@ _validation_check_policy() { return 1 fi - if ! _require_equal "$protected_count" "1" "Lifecycle policy must enable exactly one protected_only lifecycle; got $protected_count"; then - return 1 - fi - - if ! _require_equal "$fallback_count" "1" "Lifecycle policy must enable exactly one is_fallback lifecycle; got $fallback_count"; then - return 1 - fi - - local fallback_allows_subdirs - fallback_allows_subdirs=$(_policy_eval ".config.lifecycles.$fallback_lifecycle.allow_subdirs == true") - if ! _require_true "$fallback_allows_subdirs" "Fallback lifecycle '$fallback_lifecycle' must allow subdirectories"; then - return 1 - fi - - if ! _require_not_equal "$protected_lifecycle" "$fallback_lifecycle" "Protected lifecycle and fallback lifecycle must be different"; then + local development_allows_subdirs + development_allows_subdirs=$(_policy_eval '.config.lifecycles.development.allow_subdirs == true') + if ! _require_true "$development_allows_subdirs" "Development config layout must allow subdirectories"; then return 1 fi diff --git a/src/configs/lifecycle-policy.yaml b/src/configs/lifecycle-policy.yaml index 46f578b..47958c3 100644 --- a/src/configs/lifecycle-policy.yaml +++ b/src/configs/lifecycle-policy.yaml @@ -1,15 +1,13 @@ -kind: lifecyclePolicy +kind: rabbitConfigLayout version: udx.dev/rabbit-infra-config/v1 config: lifecycles: production: allow_subdirs: true - protected_only: true staging: allow_subdirs: false development: allow_subdirs: true - is_fallback: true From 1279f2c4a8a76781f0c80f042fc281c719cc89a8 Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 27 May 2026 17:47:44 +0300 Subject: [PATCH 3/8] Repair repo validation context --- .env.example | 14 +++++++++++++ .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++ .rabbit/context.yaml | 34 +++++++++++++------------------ Makefile | 19 +++++++++++++++++ README.md | 6 ++++++ docs/configuration.md | 44 ++++++++++++++++++++++++++++++++++++++++ docs/validation.md | 16 +++++++++++++++ 7 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile create mode 100644 docs/configuration.md create mode 100644 docs/validation.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f56aa1b --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Local script defaults for direct merge testing. +# The composite action normally receives these values from action inputs and +# udx/rabbit-lifecycle outputs. + +INPUT_SOURCE_DIR=.rabbit +INPUT_ENV_NAME=development +INPUT_LIFECYCLE=development +INPUT_IS_PROTECTED=false +INPUT_RESOLUTION_REASON=local-example +INPUT_OUTPUT_FORMAT=yaml +INPUT_RECURSIVE=true +INPUT_FILE_PATTERNS=*.yml,*.yaml +INPUT_EXCLUDE=**/merged*.yml,**/merged*.yaml +INPUT_DEBUG=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb63a2b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + pull_request: + push: + branches: + - production + - lifecycle-action-integration + +permissions: + contents: read + +jobs: + validate: + name: validate action contract + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pinned yq + shell: bash + run: | + set -euo pipefail + version="v4.44.3" + install_dir="${RUNNER_TEMP}/rabbit-action-bin" + mkdir -p "$install_dir" + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${version}/yq_linux_amd64" -o "$install_dir/yq" + chmod +x "$install_dir/yq" + echo "$install_dir" >> "$GITHUB_PATH" + + - name: Run validation + shell: bash + run: make test + + - name: Refresh repo context + shell: bash + run: npx -y @udx/dev-kit@0.12.0 repo diff --git a/.rabbit/context.yaml b/.rabbit/context.yaml index 870ba72..9e4dc69 100644 --- a/.rabbit/context.yaml +++ b/.rabbit/context.yaml @@ -6,7 +6,7 @@ generator: tool: dev.kit repo: https://github.com/udx/dev.kit version: 0.12.0 - generated_at: 2026-05-27T14:38:30Z + generated_at: 2026-05-27T14:47:01Z sources: homepage: https://udx.dev/kit repository: https://github.com/udx/dev.kit @@ -24,31 +24,23 @@ repo: refs: - ./README.md + - ./Makefile + - ./docs/configuration.md + - ./docs/validation.md - ./src/configs/lifecycle-policy.yaml - ./action.yml - ./.github/workflows - ./docs -# Gaps — Factors that are missing or only partially supported by current repo signals. -# Note: Base the result on explicit factor rules, not free-form judgment. -# Note: Include message and evidence so the status can be reviewed. -# Note: Prefer traceable refs and missing signals over vague advice. +# Commands — Canonical repo entrypoints detected from strong repo signals. +# Note: Prefer declared make targets and package scripts before regex matches in docs. +# Note: Emit only commands that can be traced to a concrete source. +# Note: Record the source path so the command can be reviewed and corrected. -gaps: - - factor: config - status: missing - message: No repo-owned configuration contract was found in docs, manifests, or checked-in example files. - repair_target: README.md or .env.example - reference: README.md - evidence: - - none - - factor: pipeline - status: partial - message: Found partial pipeline signals in tests, but the repo does not declare a complete validation/deploy contract yet. - repair_target: action.yml - reference: action.yml - evidence: - - tests +commands: + verify: + run: make test + source: Makefile # Dependencies — Meaningful dependency-repo contracts such as reusable workflows, images, or versioned manifests this repo relies on. # Note: Capture execution-shaping behavior defined outside the current checkout. @@ -78,6 +70,8 @@ manifests: source_repo: udx/rabbit-infra-config used_by: - bin/lib/config.sh + - docs/configuration.md evidence: - version: udx.dev/rabbit-infra-config/v1 - path reference: bin/lib/config.sh + - path reference: docs/configuration.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3777ac6 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: test validate-shell validate-action validate-workflow + +test: validate-shell validate-action validate-workflow + tests/run-merge-tests.sh + +validate-shell: + bash -n \ + bin/merge-configs.sh \ + bin/lib/config.sh \ + bin/lib/lifecycle.sh \ + bin/lib/validation.sh \ + bin/lib/environment.sh \ + tests/run-merge-tests.sh + +validate-action: + yq eval '.' action.yml >/dev/null + +validate-workflow: + yq eval '.' .github/workflows/ci.yml >/dev/null diff --git a/README.md b/README.md index 7209039..bbde245 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,8 @@ Infrastructure configs live in `.rabbit/` directories organized by lifecycle: - Root-level files in the configured `source_dir` are ignored (must be in a lifecycle directory) - Only direct lifecycle roots under `source_dir` are eligible; use `source_dir: .rabbit/infra_configs` for nested config roots +See [docs/configuration.md](docs/configuration.md) for the repo-owned Rabbit config layout and merge contract. + ### Plan Mode | Trigger | Mode | @@ -540,6 +542,10 @@ Notifications include environment, change counts, failure stage, and a link to t - **Schedule nightly runs** (`cron: "0 2 * * *"`) to detect infrastructure drift - **Keep `.rabbit/` configs small and focused** — one concern per file +## Development + +The local validation contract is documented in [docs/validation.md](docs/validation.md). Run `make test` and `dev.kit repo` before updating a PR. + --- ## License diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..6b4c75a --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,44 @@ +# Configuration Contract + +`github-rabbit-action` consumes Rabbit infrastructure config from a lifecycle-rooted source directory. The default source directory is `.rabbit`, and callers can set `source_dir` when configs live under another root such as `.rabbit/infra_configs`. + +## Layout + +Config files must live under one of the direct lifecycle roots in `source_dir`: + +```text +.rabbit/ +├── production/ +│ ├── 10-infra.yaml +│ └── customer-branch/ +│ └── 10-infra.yaml +├── staging/ +│ └── 10-infra.yaml +└── development/ + ├── 10-infra.yaml + └── feature-branch/ + └── 10-infra.yaml +``` + +Root-level files in `source_dir` are ignored. Nested lifecycle roots are also ignored unless `source_dir` points at the nested root. + +## Merge Order + +For an externally resolved lifecycle and environment, the action discovers config files in this order: + +1. Lifecycle root files, sorted by path. +2. Environment or branch override files under `/`, sorted by path. + +The lifecycle root is the base config. Environment or branch files override and extend that base config. Services with the same `module::id` are merged by the action's manifest merge logic. + +## Lifecycle Boundary + +Lifecycle, environment, protected-branch status, and resolution reason are resolved by `udx/rabbit-lifecycle` in the normal action path. This repo owns only Rabbit deployment config discovery, merge ordering, manifest merge behavior, deployment safety checks, cloud auth, Terraform/R2A execution, PR comments, summaries, and Slack notifications. + +Direct local script runs that do not provide `INPUT_LIFECYCLE` use a simple compatibility fallback: explicit lifecycle name, preferred lifecycle subdirectory, then `development`. + +## Source Contract + +The repo-owned layout manifest is [src/configs/lifecycle-policy.yaml](../src/configs/lifecycle-policy.yaml). Its `kind: rabbitConfigLayout` declares which lifecycle roots support subdirectory overrides. + +For local direct script runs, [.env.example](../.env.example) documents the merge-script environment variables that mirror action inputs and lifecycle outputs. diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..6814825 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,16 @@ +# Validation Contract + +This repository validates the composite action with shell syntax checks, action metadata parsing, and merge smoke tests. + +## Local Checks + +Run these before opening or updating a PR: + +```bash +make test +dev.kit repo +``` + +## CI + +The `ci` workflow runs on pull requests and pushes to `production` and `lifecycle-action-integration`. It installs a pinned `yq` binary, runs `make test`, and refreshes repo context with `@udx/dev-kit@0.12.0`. From 34edfb3d280704cbacf1ccff6292a649cfddc2ef Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 29 Jul 2026 17:25:16 +0300 Subject: [PATCH 4/8] Absorb Rabbit lifecycle resolution --- .rabbit/README.md | 7 +++ .rabbit/context.yaml | 7 ++- Makefile | 2 + README.md | 16 ++++--- action.yml | 39 +++++++++++------ bin/lib/config.sh | 26 +++++++++-- bin/lib/github.sh | 47 ++++++++++++++++++++ bin/lib/lifecycle.sh | 38 +++++++++++++++-- bin/lib/validation.sh | 31 +++++++++++--- bin/resolve-lifecycle.sh | 65 ++++++++++++++++++++++++++++ docs/configuration.md | 6 +-- docs/validation.md | 4 +- src/configs/lifecycle-policy.yaml | 6 +++ tests/run-merge-tests.sh | 71 +++++++++++++++++++++++++++++++ 14 files changed, 324 insertions(+), 41 deletions(-) create mode 100644 .rabbit/README.md create mode 100644 bin/lib/github.sh create mode 100755 bin/resolve-lifecycle.sh diff --git a/.rabbit/README.md b/.rabbit/README.md new file mode 100644 index 0000000..34aa0ed --- /dev/null +++ b/.rabbit/README.md @@ -0,0 +1,7 @@ +# Rabbit Context + +This directory keeps Rabbit-facing repo context and generated `dev.kit` evidence. + +- `context.yaml` is generated by `dev.kit repo`; do not edit it manually. +- Keep human-authored Rabbit CI notes, delivery entry points, and repo-specific workflow guidance in this README. +- After changing repo docs, manifests, scripts, or workflows, run `dev.kit repo` to refresh generated context. diff --git a/.rabbit/context.yaml b/.rabbit/context.yaml index 9e4dc69..69d5a67 100644 --- a/.rabbit/context.yaml +++ b/.rabbit/context.yaml @@ -5,8 +5,8 @@ version: udx.dev/dev.kit/v1 generator: tool: dev.kit repo: https://github.com/udx/dev.kit - version: 0.12.0 - generated_at: 2026-05-27T14:47:01Z + version: 0.20.1 + generated_at: 2026-07-29T14:24:32Z sources: homepage: https://udx.dev/kit repository: https://github.com/udx/dev.kit @@ -50,9 +50,8 @@ commands: dependencies: - repo: udx/rabbit-infra-config kind: manifest contract (v1) - resolved: true + resolved: false declared_as: udx.dev/rabbit-infra-config/v1 - archetype: Composite action for determine infra config files and merge them. used_by: - src/configs/lifecycle-policy.yaml diff --git a/Makefile b/Makefile index 3777ac6..7c6c8d5 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,9 @@ test: validate-shell validate-action validate-workflow validate-shell: bash -n \ bin/merge-configs.sh \ + bin/resolve-lifecycle.sh \ bin/lib/config.sh \ + bin/lib/github.sh \ bin/lib/lifecycle.sh \ bin/lib/validation.sh \ bin/lib/environment.sh \ diff --git a/README.md b/README.md index bbde245..199ed10 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,8 @@ services: │ ┌─────────────▼──────────────┐ │ 1. Resolve Lifecycle │ - │ Branch/env policy via │ - │ udx/rabbit-lifecycle │ + │ In-repo branch/env │ + │ policy and protection │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ @@ -145,27 +145,27 @@ services: └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ - │ 2. Safety Checks │ + │ 3. Safety Checks │ │ Block production manual │ │ Block production destroy │ │ Auto plan-only for PRs │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ - │ 3. Cloud Auth │ + │ 4. Cloud Auth │ │ GCP Workload Identity │ │ AWS OIDC (optional) │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ - │ 4. Terraform Engine │ + │ 5. Terraform Engine │ │ Docker: r2a container │ │ Per-service init/plan/ │ │ apply in deploy order │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ - │ 5. Reporting │ + │ 6. Reporting │ │ Plan summary table │ │ PR comment │ │ GitHub step summary │ @@ -175,7 +175,7 @@ services: ### Environment Detection -The environment is automatically resolved from the workflow event, then passed to `udx/rabbit-lifecycle` for lifecycle policy resolution: +The environment is automatically resolved from the workflow event, then resolved against this action's lifecycle policy: | Trigger | Environment Source | | --- | --- | @@ -481,6 +481,7 @@ The workflow dispatch inputs provide safe manual control: | `newrelic_api_key` | — | — | New Relic API key | | `slack_webhook` | — | — | Slack webhook URL | | `source_dir` | — | `.rabbit` | Config source directory | +| `lifecycle_policy_path` | — | bundled policy | Optional caller-repository lifecycle policy shared by resolution and config merging | | `github_token` | — | `github.token` | GitHub token passed to lifecycle resolution and used for PR comments | ## Outputs @@ -491,6 +492,7 @@ The workflow dispatch inputs provide safe manual control: | `lifecycle` | Resolved lifecycle (production/staging/development) | | `is_protected` | Whether GitHub reported the environment branch as protected | | `resolution_reason` | Lifecycle rule that selected the lifecycle | +| `lifecycle_policy_path` | Lifecycle policy used for resolution and config merging | | `plan_only` | Whether run was plan-only | | `terraform_action` | Action executed (apply/destroy/skip) | | `has_changes` | Whether Terraform detected changes | diff --git a/action.yml b/action.yml index 4c8c7af..7700905 100644 --- a/action.yml +++ b/action.yml @@ -87,6 +87,9 @@ inputs: description: "Source directory for .rabbit configs" required: false default: ".rabbit" + lifecycle_policy_path: + description: "Optional caller-repository lifecycle policy path; defaults to the bundled policy" + required: false github_token: description: "GitHub token passed to lifecycle resolution and used for PR comments" required: false @@ -105,6 +108,9 @@ outputs: resolution_reason: description: "Lifecycle rule that selected the lifecycle" value: ${{ steps.resolve-lifecycle.outputs.resolution_reason }} + lifecycle_policy_path: + description: "Lifecycle policy path used for resolution and config merging" + value: ${{ steps.resolve-lifecycle.outputs.lifecycle_policy_path }} plan_only: description: "Whether the run was plan-only" value: ${{ steps.plan-mode.outputs.plan_only }} @@ -131,18 +137,7 @@ runs: using: "composite" steps: # ========================================================================= - # 1. Resolve Rabbit lifecycle - # ========================================================================= - - name: Resolve Rabbit lifecycle - id: resolve-lifecycle - uses: udx/rabbit-lifecycle@lifecycle-action-contract - with: - source_dir: ${{ inputs.source_dir }} - env_name: ${{ inputs.environment || (github.event_name == 'pull_request' && github.base_ref) || (github.event_name == 'delete' && github.event.ref) || github.ref_name }} - github_token: ${{ inputs.github_token }} - - # ========================================================================= - # 2. Install pinned yq + # 1. Install pinned yq # ========================================================================= - name: Install pinned yq shell: bash @@ -159,6 +154,23 @@ runs: export PATH="$install_dir:$PATH" yq --version + # ========================================================================= + # 2. Resolve Rabbit lifecycle + # ========================================================================= + - name: Resolve Rabbit lifecycle + id: resolve-lifecycle + shell: bash + env: + INPUT_SOURCE_DIR: ${{ inputs.source_dir }} + INPUT_LIFECYCLE_POLICY_PATH: ${{ inputs.lifecycle_policy_path }} + INPUT_ENV_NAME: ${{ inputs.environment || (github.event_name == 'pull_request' && github.base_ref) || (github.event_name == 'delete' && github.event.ref) || github.ref_name }} + GITHUB_TOKEN: ${{ inputs.github_token }} + ENABLE_ANNOTATIONS: "false" + INPUT_DEBUG: "false" + run: | + set -euo pipefail + bash "${GITHUB_ACTION_PATH}/bin/resolve-lifecycle.sh" + # ========================================================================= # 3. Merge infrastructure configs # ========================================================================= @@ -167,6 +179,7 @@ runs: shell: bash env: INPUT_SOURCE_DIR: ${{ inputs.source_dir }} + INPUT_LIFECYCLE_POLICY_PATH: ${{ inputs.lifecycle_policy_path }} INPUT_ENV_NAME: ${{ steps.resolve-lifecycle.outputs.environment }} INPUT_LIFECYCLE: ${{ steps.resolve-lifecycle.outputs.lifecycle }} INPUT_IS_PROTECTED: ${{ steps.resolve-lifecycle.outputs.is_protected }} @@ -285,6 +298,7 @@ runs: shell: bash env: LIFECYCLE: ${{ steps.resolve-env.outputs.lifecycle }} + LIFECYCLE_POLICY_PATH: ${{ steps.resolve-lifecycle.outputs.lifecycle_policy_path }} PLAN_ONLY: ${{ steps.plan-mode.outputs.plan_only }} TERRAFORM_ACTION: ${{ steps.terraform-action.outputs.terraform_action }} EVENT_NAME: ${{ github.event_name }} @@ -402,6 +416,7 @@ runs: echo "| Field | Value |" echo "| --- | --- |" echo "| Lifecycle | \`${LIFECYCLE:-unknown}\` |" + echo "| Lifecycle Policy | \`${LIFECYCLE_POLICY_PATH:-bundled}\` |" echo "| Plan Only | \`${PLAN_ONLY:-true}\` |" echo "| Multi Repo | \`${MULTI_REPO:-false}\` |" echo "| AWS Enabled | \`${HAS_AWS}\` |" diff --git a/bin/lib/config.sh b/bin/lib/config.sh index 30ed5ec..fd70482 100755 --- a/bin/lib/config.sh +++ b/bin/lib/config.sh @@ -8,7 +8,22 @@ fi LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$LIB_DIR/../.." && pwd)" -LIFECYCLE_POLICY_FILE="${LIFECYCLE_POLICY_FILE:-$PROJECT_ROOT/src/configs/lifecycle-policy.yaml}" +BUNDLED_LIFECYCLE_POLICY_FILE="$PROJECT_ROOT/src/configs/lifecycle-policy.yaml" + +# A caller may provide a policy relative to the checked-out repository. The +# same resolved file is used by lifecycle resolution and config merging. +if [[ -z "${LIFECYCLE_POLICY_FILE:-}" ]]; then + if [[ -n "${INPUT_LIFECYCLE_POLICY_PATH:-}" ]]; then + if [[ "$INPUT_LIFECYCLE_POLICY_PATH" == /* ]]; then + LIFECYCLE_POLICY_FILE="$INPUT_LIFECYCLE_POLICY_PATH" + else + LIFECYCLE_POLICY_FILE="${GITHUB_WORKSPACE:-$PWD}/$INPUT_LIFECYCLE_POLICY_PATH" + fi + else + LIFECYCLE_POLICY_FILE="$BUNDLED_LIFECYCLE_POLICY_FILE" + fi +fi +export LIFECYCLE_POLICY_FILE _yaml_get_or_default() { local query="$1" @@ -61,7 +76,8 @@ DEFAULT_LIFECYCLE_DEVELOPMENT="development" DEFAULT_SUBDIRECTORY_LIFECYCLES="$(_yaml_csv_or_default '.config.lifecycles | to_entries | map(select(.value.allow_subdirs == true) | .key)' 'production,development')" DEFAULT_STABLE_LIFECYCLES="$(_yaml_csv_or_default '.config.lifecycles | to_entries | map(select(.value.allow_subdirs != true) | .key)' 'staging')" DEFAULT_ALL_LIFECYCLES="production,staging,development" -DEFAULT_FALLBACK_LIFECYCLE="$DEFAULT_LIFECYCLE_DEVELOPMENT" +DEFAULT_PROTECTED_BRANCH_LIFECYCLE="$(_yaml_get_or_default '.config.lifecycles | to_entries | map(select(.value.protected_only == true) | .key) | .[0]' "$DEFAULT_LIFECYCLE_PRODUCTION")" +DEFAULT_FALLBACK_LIFECYCLE="$(_yaml_get_or_default '.config.lifecycles | to_entries | map(select(.value.is_fallback == true) | .key) | .[0]' "$DEFAULT_LIFECYCLE_DEVELOPMENT")" DEFAULT_SUBDIR_PREFERRED_LIFECYCLE="${DEFAULT_FALLBACK_LIFECYCLE}" export LIFECYCLE_PRODUCTION="${INPUT_LIFECYCLE_PRODUCTION:-$DEFAULT_LIFECYCLE_PRODUCTION}" @@ -76,10 +92,11 @@ export SUBDIRECTORY_LIFECYCLES="${INPUT_SUBDIRECTORY_LIFECYCLES:-$DEFAULT_SUBDIR export STABLE_LIFECYCLES_STR="${INPUT_STABLE_LIFECYCLES:-$DEFAULT_STABLE_LIFECYCLES}" export ALL_LIFECYCLES_STR="${INPUT_ALL_LIFECYCLES:-$DEFAULT_ALL_LIFECYCLES}" export SUBDIR_PREFERRED_LIFECYCLE="${INPUT_SUBDIR_PREFERRED_LIFECYCLE:-$DEFAULT_SUBDIR_PREFERRED_LIFECYCLE}" +export PROTECTED_BRANCH_LIFECYCLE="${INPUT_PROTECTED_BRANCH_LIFECYCLE:-$DEFAULT_PROTECTED_BRANCH_LIFECYCLE}" export FALLBACK_LIFECYCLE="${INPUT_FALLBACK_LIFECYCLE:-$DEFAULT_FALLBACK_LIFECYCLE}" -# Exported lifecycle metadata. INPUT_LIFECYCLE is supplied by udx/rabbit-lifecycle -# in the composite action path; local script runs can still resolve internally. +# Exported lifecycle metadata. The composite action resolves these in-repo; +# local script runs retain the compatibility fallback when no value is supplied. export LIFECYCLE="${INPUT_LIFECYCLE:-}" export IS_PROTECTED="${INPUT_IS_PROTECTED:-}" export RESOLUTION_REASON="${INPUT_RESOLUTION_REASON:-}" @@ -96,6 +113,7 @@ export RECURSIVE="${INPUT_RECURSIVE:-true}" export FILE_PATTERNS="${INPUT_FILE_PATTERNS:-*.yml,*.yaml}" export OUTPUT_FORMAT="${INPUT_OUTPUT_FORMAT:-yaml}" export DEBUG="${INPUT_DEBUG:-false}" +export ENABLE_ANNOTATIONS="${ENABLE_ANNOTATIONS:-false}" export GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/null}" readonly CONFIG_LIB_LOADED="true" diff --git a/bin/lib/github.sh b/bin/lib/github.sh new file mode 100644 index 0000000..c90b671 --- /dev/null +++ b/bin/lib/github.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Minimal GitHub API helpers for lifecycle resolution. + +if [[ "${GITHUB_LIB_LOADED:-}" == "true" ]]; then + return 0 +fi + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$LIB_DIR/logging.sh" + +readonly GITHUB_API_BASE="https://api.github.com" +readonly GITHUB_API_VERSION="2022-11-28" + +# Return success only when GitHub confirms the branch is protected. A missing +# token or an unavailable branch remains compatible with the historical +# fallback-to-development behavior, but is visible in debug logs. +github_check_branch_protection() { + local branch="$1" + local repository="${2:-${GITHUB_REPOSITORY:-}}" + local token="${3:-${GITHUB_TOKEN:-}}" + + if [[ -z "$branch" || -z "$repository" || -z "$token" ]]; then + dbg "Skipping branch protection check because branch, repository, or token is unavailable" + return 1 + fi + + local response http_code body + response="$(curl -sS -w $'\n%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: $GITHUB_API_VERSION" \ + "$GITHUB_API_BASE/repos/$repository/branches/$branch" 2>/dev/null || true)" + http_code="${response##*$'\n'}" + body="${response%$'\n'*}" + + if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then + if grep -q '"protected"[[:space:]]*:[[:space:]]*true' <<<"$body"; then + return 0 + fi + return 1 + fi + + warn "Could not determine branch protection for '$branch' (GitHub API HTTP ${http_code:-unknown}); using lifecycle fallback rules" + return 1 +} + +readonly GITHUB_LIB_LOADED="true" diff --git a/bin/lib/lifecycle.sh b/bin/lib/lifecycle.sh index f8820a1..f2864ee 100755 --- a/bin/lib/lifecycle.sh +++ b/bin/lib/lifecycle.sh @@ -20,8 +20,8 @@ source "$LIB_DIR/discovery.sh" # # Lifecycle Rules (applied throughout the codebase): # - Only configured subdirectory lifecycles support lifecycle// smart merge -# - Pre-resolved lifecycle metadata comes from udx/rabbit-lifecycle in the action path -# - Local fallback does not inspect GitHub branch protection +# - The composite action resolves lifecycle metadata before config merging +# - Local direct script runs retain a simple fallback without GitHub metadata # Build lifecycle arrays from environment variables IFS=',' read -ra STABLE_LIFECYCLES <<< "${STABLE_LIFECYCLES_STR}" @@ -99,8 +99,8 @@ _find_env_directory() { echo "$result" } -# PRIVATE: Minimal local fallback for direct script usage. -# The composite action passes INPUT_LIFECYCLE from udx/rabbit-lifecycle. +# PRIVATE: Minimal fallback for direct script usage when no lifecycle metadata +# is supplied. The composite action resolves lifecycle before config merging. _determine_lifecycle_for_env() { local env_name="$1" shift @@ -127,6 +127,36 @@ _determine_lifecycle_for_env() { echo "$FALLBACK_LIFECYCLE" } +# PUBLIC: Resolve a lifecycle and preserve the rule that selected it. +# Usage: lifecycle_resolve "environment" "is_protected" "directories..." +lifecycle_resolve() { + local env_name="$1" + local is_protected="$2" + shift 2 + local unique_dirs=("$@") + local lifecycle + + for lifecycle in "${ALL_LIFECYCLES[@]}"; do + if [[ "$env_name" == "$lifecycle" ]]; then + echo "$lifecycle|explicit_lifecycle" + return 0 + fi + done + + if _lifecycle_allows_subdirectories "$SUBDIR_PREFERRED_LIFECYCLE" && \ + discovery_find_dir_matching "*/$SUBDIR_PREFERRED_LIFECYCLE/$env_name" "${unique_dirs[@]}" >/dev/null; then + echo "$SUBDIR_PREFERRED_LIFECYCLE|environment_subdirectory" + return 0 + fi + + if [[ "$is_protected" == "true" ]]; then + echo "$PROTECTED_BRANCH_LIFECYCLE|protected_branch" + return 0 + fi + + echo "$FALLBACK_LIFECYCLE|fallback" +} + # PUBLIC: Get lifecycle and directory info for environment # Returns: lifecycle|directory|is_protected (pipe-separated) # Usage: result=$(lifecycle_get_info "$env_name" "unique_dirs_array") diff --git a/bin/lib/validation.sh b/bin/lib/validation.sh index 01a1175..837b8f0 100755 --- a/bin/lib/validation.sh +++ b/bin/lib/validation.sh @@ -75,16 +75,24 @@ _validation_check_policy() { return 1 fi - local kind version lifecycle_keys + local kind version lifecycle_keys protected_count fallback_count protected_lifecycle fallback_lifecycle mapfile -t policy_meta < <(_policy_eval ' .kind, .version, - (.config.lifecycles | keys | join(",")) + (.config.lifecycles | keys | join(",")), + (.config.lifecycles | to_entries | map(select(.value.protected_only == true)) | length), + (.config.lifecycles | to_entries | map(select(.value.is_fallback == true)) | length), + (.config.lifecycles | to_entries | map(select(.value.protected_only == true) | .key) | .[0]), + (.config.lifecycles | to_entries | map(select(.value.is_fallback == true) | .key) | .[0]) ') kind="${policy_meta[0]:-}" version="${policy_meta[1]:-}" lifecycle_keys="${policy_meta[2]:-}" + protected_count="${policy_meta[3]:-0}" + fallback_count="${policy_meta[4]:-0}" + protected_lifecycle="${policy_meta[5]:-}" + fallback_lifecycle="${policy_meta[6]:-}" if ! _require_equal "$kind" "rabbitConfigLayout" "Rabbit config layout kind must be 'rabbitConfigLayout', got '$kind'"; then return 1 @@ -98,9 +106,22 @@ _validation_check_policy() { return 1 fi - local development_allows_subdirs - development_allows_subdirs=$(_policy_eval '.config.lifecycles.development.allow_subdirs == true') - if ! _require_true "$development_allows_subdirs" "Development config layout must allow subdirectories"; then + if ! _require_equal "$protected_count" "1" "Lifecycle policy must enable exactly one protected_only lifecycle; got $protected_count"; then + return 1 + fi + + if ! _require_equal "$fallback_count" "1" "Lifecycle policy must enable exactly one is_fallback lifecycle; got $fallback_count"; then + return 1 + fi + + local fallback_allows_subdirs + fallback_allows_subdirs=$(_policy_eval ".config.lifecycles.$fallback_lifecycle.allow_subdirs == true") + if ! _require_true "$fallback_allows_subdirs" "Fallback lifecycle '$fallback_lifecycle' must allow subdirectories"; then + return 1 + fi + + if [[ "$protected_lifecycle" == "$fallback_lifecycle" ]]; then + err "Protected lifecycle and fallback lifecycle must be different" return 1 fi diff --git a/bin/resolve-lifecycle.sh b/bin/resolve-lifecycle.sh new file mode 100755 index 0000000..1c3cc9d --- /dev/null +++ b/bin/resolve-lifecycle.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/lib/config.sh" +source "$SCRIPT_DIR/lib/logging.sh" +source "$SCRIPT_DIR/lib/validation.sh" +source "$SCRIPT_DIR/lib/discovery.sh" +source "$SCRIPT_DIR/lib/github.sh" +source "$SCRIPT_DIR/lib/lifecycle.sh" + +write_output() { + local key="$1" + local value="$2" + printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" +} + +collect_dirs() { + local source_dir="$1" + local -n dirs_ref=$2 + dirs_ref=() + + if [[ ! -d "$source_dir" ]]; then + dbg "Source directory '$source_dir' does not exist; resolving without directory hints" + return 0 + fi + + while IFS= read -r dir; do + dirs_ref+=("$dir") + done < <(find "$source_dir" -type d -mindepth 1 2>/dev/null | sort) +} + +main() { + if [[ -z "$ENV_NAME" ]]; then + err "Environment name is required. Pass environment or run inside a GitHub event with a resolvable ref." + exit 1 + fi + + if ! validation_check_policy; then + exit 1 + fi + + local directories=() + collect_dirs "$SOURCE_DIR" directories + + local is_protected="false" + if github_check_branch_protection "$ENV_NAME"; then + is_protected="true" + fi + + local resolution lifecycle reason + resolution="$(lifecycle_resolve "$ENV_NAME" "$is_protected" "${directories[@]}")" + lifecycle="${resolution%%|*}" + reason="${resolution#*|}" + + log "Resolved environment '$ENV_NAME' to lifecycle '$lifecycle' ($reason)" + write_output "environment" "$ENV_NAME" + write_output "lifecycle" "$lifecycle" + write_output "is_protected" "$is_protected" + write_output "resolution_reason" "$reason" + write_output "lifecycle_policy_path" "$LIFECYCLE_POLICY_FILE" +} + +main "$@" diff --git a/docs/configuration.md b/docs/configuration.md index 6b4c75a..5418e8e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,7 +24,7 @@ Root-level files in `source_dir` are ignored. Nested lifecycle roots are also ig ## Merge Order -For an externally resolved lifecycle and environment, the action discovers config files in this order: +For the lifecycle and environment resolved by this action, config files are discovered in this order: 1. Lifecycle root files, sorted by path. 2. Environment or branch override files under `/`, sorted by path. @@ -33,12 +33,12 @@ The lifecycle root is the base config. Environment or branch files override and ## Lifecycle Boundary -Lifecycle, environment, protected-branch status, and resolution reason are resolved by `udx/rabbit-lifecycle` in the normal action path. This repo owns only Rabbit deployment config discovery, merge ordering, manifest merge behavior, deployment safety checks, cloud auth, Terraform/R2A execution, PR comments, summaries, and Slack notifications. +This action resolves lifecycle, environment, protected-branch status, and resolution reason before merging configuration. It then owns Rabbit config discovery, merge ordering, manifest merge behavior, deployment safety checks, cloud auth, Terraform/R2A execution, PR comments, summaries, and Slack notifications. It has no runtime dependency on `udx/rabbit-lifecycle`. Direct local script runs that do not provide `INPUT_LIFECYCLE` use a simple compatibility fallback: explicit lifecycle name, preferred lifecycle subdirectory, then `development`. ## Source Contract -The repo-owned layout manifest is [src/configs/lifecycle-policy.yaml](../src/configs/lifecycle-policy.yaml). Its `kind: rabbitConfigLayout` declares which lifecycle roots support subdirectory overrides. +The bundled layout manifest is [src/configs/lifecycle-policy.yaml](../src/configs/lifecycle-policy.yaml). Its `kind: rabbitConfigLayout` declares which lifecycle roots support subdirectory overrides, which lifecycle is selected for a protected branch, and which is the fallback. Set the optional `lifecycle_policy_path` input to a caller-repository policy when a repository needs a different policy; the same selected policy is validated and used for both lifecycle resolution and merging. For local direct script runs, [.env.example](../.env.example) documents the merge-script environment variables that mirror action inputs and lifecycle outputs. diff --git a/docs/validation.md b/docs/validation.md index 6814825..d3d67d9 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,6 +1,6 @@ # Validation Contract -This repository validates the composite action with shell syntax checks, action metadata parsing, and merge smoke tests. +This repository validates the composite action with shell syntax checks, action metadata parsing, lifecycle-resolution scenarios, and config-merge smoke tests. ## Local Checks @@ -13,4 +13,4 @@ dev.kit repo ## CI -The `ci` workflow runs on pull requests and pushes to `production` and `lifecycle-action-integration`. It installs a pinned `yq` binary, runs `make test`, and refreshes repo context with `@udx/dev-kit@0.12.0`. +The `ci` workflow runs on pull requests and pushes to `production` and `lifecycle-action-integration`. It installs a pinned `yq` binary, runs `make test`, and refreshes repo context with `@udx/dev-kit`. diff --git a/src/configs/lifecycle-policy.yaml b/src/configs/lifecycle-policy.yaml index 47958c3..845a922 100644 --- a/src/configs/lifecycle-policy.yaml +++ b/src/configs/lifecycle-policy.yaml @@ -5,9 +5,15 @@ config: lifecycles: production: allow_subdirs: true + protected_only: true + is_fallback: false staging: allow_subdirs: false + protected_only: false + is_fallback: false development: allow_subdirs: true + protected_only: false + is_fallback: true diff --git a/tests/run-merge-tests.sh b/tests/run-merge-tests.sh index db68e66..7d0a879 100755 --- a/tests/run-merge-tests.sh +++ b/tests/run-merge-tests.sh @@ -30,16 +30,40 @@ run_merge() { local env_name="$2" local lifecycle="$3" local output_file="$4" + local policy_path="${5:-}" : > "$output_file" + GITHUB_WORKSPACE="$ROOT" \ INPUT_SOURCE_DIR="$source_dir" \ INPUT_ENV_NAME="$env_name" \ INPUT_LIFECYCLE="$lifecycle" \ + INPUT_LIFECYCLE_POLICY_PATH="$policy_path" \ INPUT_IS_PROTECTED="false" \ GITHUB_OUTPUT="$output_file" \ "$PROJECT_ROOT/bin/merge-configs.sh" >/dev/null } +run_resolve() { + local source_dir="$1" + local env_name="$2" + local output_file="$3" + local policy_path="${4:-}" + local mock_bin="${5:-}" + local github_token="${6:-}" + local github_repository="${7:-}" + + : > "$output_file" + PATH="${mock_bin:+$mock_bin:}$PATH" \ + GITHUB_WORKSPACE="$ROOT" \ + INPUT_SOURCE_DIR="$source_dir" \ + INPUT_ENV_NAME="$env_name" \ + INPUT_LIFECYCLE_POLICY_PATH="$policy_path" \ + GITHUB_TOKEN="$github_token" \ + GITHUB_REPOSITORY="$github_repository" \ + GITHUB_OUTPUT="$output_file" \ + "$PROJECT_ROOT/bin/resolve-lifecycle.sh" >/dev/null +} + assert_eq() { local actual="$1" local expected="$2" @@ -121,6 +145,23 @@ write_yaml "$INFRA_SOURCE/production/main/20-override.yaml" 'services: id: infra replicas: 5' +write_yaml "$SOURCE/lifecycle-policy.yaml" 'kind: rabbitConfigLayout +version: udx.dev/rabbit-infra-config/v1 +config: + lifecycles: + production: + allow_subdirs: true + protected_only: true + is_fallback: false + staging: + allow_subdirs: false + protected_only: false + is_fallback: false + development: + allow_subdirs: true + protected_only: false + is_fallback: true' + echo "Rabbit config merge smoke tests" echo "===============================" @@ -159,6 +200,36 @@ infra_config="$(read_output "$infra_out" merged_config)" assert_file_exists "$infra_config" "Merged infra_configs config exists" assert_eq "$(yq -r '.services[0].replicas' "$infra_config")" "5" "infra_configs override wins" +scenario "Scenario: in-repo lifecycle resolver honors explicit and subdirectory rules" +explicit_out="$TEMP_DIR/explicit.out" +run_resolve "$SOURCE" "staging" "$explicit_out" +assert_eq "$(read_output "$explicit_out" lifecycle)" "staging" "Explicit lifecycle resolves in-repo" +assert_eq "$(read_output "$explicit_out" resolution_reason)" "explicit_lifecycle" "Explicit lifecycle reason is recorded" + +subdir_out="$TEMP_DIR/subdir.out" +run_resolve "$SOURCE" "dev-alice" "$subdir_out" +assert_eq "$(read_output "$subdir_out" lifecycle)" "development" "Development subdirectory resolves in-repo" +assert_eq "$(read_output "$subdir_out" resolution_reason)" "environment_subdirectory" "Subdirectory reason is recorded" + +scenario "Scenario: resolver uses a caller lifecycle policy for both policy metadata and merge" +policy_out="$TEMP_DIR/policy.out" +run_resolve "$SOURCE" "dev-alice" "$policy_out" ".rabbit/lifecycle-policy.yaml" +assert_eq "$(read_output "$policy_out" lifecycle_policy_path)" "$SOURCE/lifecycle-policy.yaml" "Caller policy path is reported" +run_merge "$SOURCE" "dev-alice" "development" "$TEMP_DIR/policy-merge.out" ".rabbit/lifecycle-policy.yaml" +assert_file_exists "$(read_output "$TEMP_DIR/policy-merge.out" merged_config)" "Merge remains compatible with caller policy" + +scenario "Scenario: protected branch resolves to production" +mock_bin="$TEMP_DIR/mock-bin" +mkdir -p "$mock_bin" +write_yaml "$mock_bin/curl" "#!/usr/bin/env bash +printf '%s\\n%s\\n' '{\"protected\":true}' '200'" +chmod +x "$mock_bin/curl" +protected_out="$TEMP_DIR/protected.out" +run_resolve "$SOURCE" "main" "$protected_out" "" "$mock_bin" "test-token" "udx/github-rabbit-action" +assert_eq "$(read_output "$protected_out" lifecycle)" "production" "Protected branch resolves to production" +assert_eq "$(read_output "$protected_out" is_protected)" "true" "Protected status is recorded" +assert_eq "$(read_output "$protected_out" resolution_reason)" "protected_branch" "Protected branch reason is recorded" + echo "" echo "Results: $PASSED passed, $FAILED failed" From e77f3cefb8e3e39138011f06f6b59cb1b7cfcd9b Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 29 Jul 2026 17:34:41 +0300 Subject: [PATCH 5/8] Verify pinned yq downloads --- .github/workflows/ci.yml | 2 ++ action.yml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb63a2b..2c629a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,11 @@ jobs: run: | set -euo pipefail version="v4.44.3" + sha256="a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7" install_dir="${RUNNER_TEMP}/rabbit-action-bin" mkdir -p "$install_dir" curl -fsSL "https://github.com/mikefarah/yq/releases/download/${version}/yq_linux_amd64" -o "$install_dir/yq" + printf '%s %s\n' "$sha256" "$install_dir/yq" | sha256sum --check --status chmod +x "$install_dir/yq" echo "$install_dir" >> "$GITHUB_PATH" diff --git a/action.yml b/action.yml index 7700905..5456535 100644 --- a/action.yml +++ b/action.yml @@ -144,11 +144,13 @@ runs: run: | set -euo pipefail version="v4.44.3" + sha256="a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7" install_dir="${RUNNER_TEMP:-/tmp}/rabbit-action-bin" mkdir -p "$install_dir" url="https://github.com/mikefarah/yq/releases/download/${version}/yq_linux_amd64" curl -fsSL "$url" -o "$install_dir/yq" + printf '%s %s\n' "$sha256" "$install_dir/yq" | sha256sum --check --status chmod +x "$install_dir/yq" echo "$install_dir" >> "$GITHUB_PATH" export PATH="$install_dir:$PATH" @@ -400,6 +402,7 @@ runs: TRIGGER: ${{ github.event_name }} R2A_VERSION: ${{ inputs.r2a_version }} MULTI_REPO: ${{ inputs.multi_repo }} + LIFECYCLE_POLICY_PATH: ${{ steps.resolve-lifecycle.outputs.lifecycle_policy_path }} HAS_AWS: ${{ inputs.aws_role_arn != '' && inputs.aws_region != '' }} run: | config_items="$(printf '%s' "${DETECTED_CONFIGS:-none}" | sed "s/#{Environment}/$ENV_NAME/g" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | sed '/^$/d')" From 5d77b5f2de0968029c5bc5183502394eb491b2be Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 29 Jul 2026 17:40:01 +0300 Subject: [PATCH 6/8] Consolidate compatible action runtime updates --- .rabbit/context.yaml | 2 +- README.md | 17 +++++- action.yml | 124 +++++++++++++++++++++++++++------------ tests/run-merge-tests.sh | 7 +++ 4 files changed, 108 insertions(+), 42 deletions(-) diff --git a/.rabbit/context.yaml b/.rabbit/context.yaml index 69d5a67..fedfee5 100644 --- a/.rabbit/context.yaml +++ b/.rabbit/context.yaml @@ -6,7 +6,7 @@ generator: tool: dev.kit repo: https://github.com/udx/dev.kit version: 0.20.1 - generated_at: 2026-07-29T14:24:32Z + generated_at: 2026-07-29T14:39:41Z sources: homepage: https://udx.dev/kit repository: https://github.com/udx/dev.kit diff --git a/README.md b/README.md index 199ed10..b1df8df 100644 --- a/README.md +++ b/README.md @@ -457,13 +457,21 @@ The workflow dispatch inputs provide safe manual control: --- +## Authentication and state ownership + +Existing workflows can keep passing `gcp_auth_provider`, `gcp_service_account`, and optional AWS role inputs to this action. To let the workflow own cloud authentication instead, omit both GCP inputs and run the appropriate provider authentication step first. The action forwards the resulting GCP credential file and any configured AWS environment credentials to its container without copying the GCP credential into the workspace. + +The optional state-backend inputs are passed through to the IaC engine. Omit them to retain its existing GCS default; provide the backend type, configuration, and state-path key only when the selected backend requires an override. + +--- + ## Inputs Reference | Input | Required | Default | Description | | --- | --- | --- | --- | -| `project_id` | ✅ | — | GCP project ID | -| `gcp_auth_provider` | ✅ | — | GCP Workload Identity Provider | -| `gcp_service_account` | ✅ | — | GCP Service Account email | +| `project_id` | ✅ | — | Project identifier for state and resource operations | +| `gcp_auth_provider` | — | — | GCP Workload Identity Provider; pair with `gcp_service_account`, or omit both after authenticating in the workflow | +| `gcp_service_account` | — | — | GCP service account; pair with `gcp_auth_provider`, or omit both after authenticating in the workflow | | `aws_role_arn` | — | — | AWS IAM OIDC role ARN | | `aws_region` | — | — | AWS region | | `dockerhub_username` | — | — | Docker Hub username | @@ -480,6 +488,9 @@ The workflow dispatch inputs provide safe manual control: | `newrelic_account_id` | — | — | New Relic account ID | | `newrelic_api_key` | — | — | New Relic API key | | `slack_webhook` | — | — | Slack webhook URL | +| `state_backend` | — | container default (GCS) | Optional backend override, such as `s3`, `azurerm`, `http`, or `consul` | +| `state_backend_config` | — | — | Backend configuration as `key=value` lines | +| `state_prefix_key` | — | — | Backend configuration key for the state path, such as `prefix` or `key` | | `source_dir` | — | `.rabbit` | Config source directory | | `lifecycle_policy_path` | — | bundled policy | Optional caller-repository lifecycle policy shared by resolution and config merging | | `github_token` | — | `github.token` | GitHub token passed to lifecycle resolution and used for PR comments | diff --git a/action.yml b/action.yml index 5456535..d161efa 100644 --- a/action.yml +++ b/action.yml @@ -9,14 +9,14 @@ branding: inputs: # Required project_id: - description: "GCP project ID for Terraform state and resource operations" + description: "Project identifier for Terraform state and resource operations" required: true gcp_auth_provider: - description: "GCP Workload Identity Federation provider resource name" - required: true + description: "Optional GCP Workload Identity Federation provider; omit with gcp_service_account to use caller-managed credentials" + required: false gcp_service_account: - description: "GCP service account email for Workload Identity" - required: true + description: "Optional GCP service account; omit with gcp_auth_provider to use caller-managed credentials" + required: false # Auth aws_role_arn: @@ -82,6 +82,17 @@ inputs: description: "Slack incoming webhook URL for deployment notifications" required: false + # State backend + state_backend: + description: "Optional state backend override passed to the IaC engine (for example s3, azurerm, http, or consul); omit to use the container default (GCS)" + required: false + state_backend_config: + description: "Optional backend configuration as key=value lines" + required: false + state_prefix_key: + description: "Optional backend configuration key for the state path (for example prefix or key)" + required: false + # Config source_dir: description: "Source directory for .rabbit configs" @@ -403,7 +414,7 @@ runs: R2A_VERSION: ${{ inputs.r2a_version }} MULTI_REPO: ${{ inputs.multi_repo }} LIFECYCLE_POLICY_PATH: ${{ steps.resolve-lifecycle.outputs.lifecycle_policy_path }} - HAS_AWS: ${{ inputs.aws_role_arn != '' && inputs.aws_region != '' }} + AWS_ROLE_CONFIGURED: ${{ inputs.aws_role_arn != '' && inputs.aws_region != '' }} run: | config_items="$(printf '%s' "${DETECTED_CONFIGS:-none}" | sed "s/#{Environment}/$ENV_NAME/g" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | sed '/^$/d')" @@ -422,7 +433,7 @@ runs: echo "| Lifecycle Policy | \`${LIFECYCLE_POLICY_PATH:-bundled}\` |" echo "| Plan Only | \`${PLAN_ONLY:-true}\` |" echo "| Multi Repo | \`${MULTI_REPO:-false}\` |" - echo "| AWS Enabled | \`${HAS_AWS}\` |" + echo "| AWS Role Input | \`${AWS_ROLE_CONFIGURED}\` |" echo "" echo "### Detected Configs" echo "" @@ -444,18 +455,34 @@ runs: } >> "$GITHUB_STEP_SUMMARY" # ========================================================================= - # 11. Authenticate with Google Cloud + # 11. Validate optional built-in GCP authentication # ========================================================================= - - name: Authenticate with Google Cloud - id: gcp-auth + - name: Validate GCP authentication inputs if: steps.safety.outputs.should_deploy == 'true' + shell: bash + env: + GCP_AUTH_PROVIDER: ${{ inputs.gcp_auth_provider }} + GCP_SERVICE_ACCOUNT: ${{ inputs.gcp_service_account }} + run: | + set -euo pipefail + + if [[ -n "$GCP_AUTH_PROVIDER" && -z "$GCP_SERVICE_ACCOUNT" ]] || [[ -z "$GCP_AUTH_PROVIDER" && -n "$GCP_SERVICE_ACCOUNT" ]]; then + echo "::error title=GCP authentication inputs incomplete::Provide both gcp_auth_provider and gcp_service_account, or omit both and authenticate before this action." + exit 1 + fi + + # ========================================================================= + # 12. Authenticate with Google Cloud when configured by the caller + # ========================================================================= + - name: Authenticate with Google Cloud + if: steps.safety.outputs.should_deploy == 'true' && inputs.gcp_auth_provider != '' && inputs.gcp_service_account != '' uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ inputs.gcp_auth_provider }} service_account: ${{ inputs.gcp_service_account }} # ========================================================================= - # 12. Configure AWS credentials (optional) + # 13. Configure AWS credentials (optional) # ========================================================================= - name: Configure AWS credentials if: steps.safety.outputs.should_deploy == 'true' && inputs.aws_role_arn != '' && inputs.aws_region != '' @@ -465,7 +492,7 @@ runs: aws-region: ${{ inputs.aws_region }} # ========================================================================= - # 13. Prepare artifacts directory + # 14. Prepare artifacts directory # ========================================================================= - name: Prepare artifacts directory if: steps.safety.outputs.should_deploy == 'true' @@ -475,22 +502,31 @@ runs: chmod -R 777 terraform/plans # ========================================================================= - # 14. Prepare GCP credentials for Docker volume mount + # 15. Resolve GCP credentials for a read-only Docker mount # ========================================================================= - - name: Prepare Workload Identity credentials + - name: Resolve GCP credentials id: prepare-creds if: steps.safety.outputs.should_deploy == 'true' shell: bash - env: - CRED_FILE_PATH: ${{ steps.gcp-auth.outputs.credentials_file_path }} run: | set -euo pipefail - cp "$CRED_FILE_PATH" gcp-credentials.json - chmod 644 gcp-credentials.json - echo "credentials_path=$(pwd)/gcp-credentials.json" >> "$GITHUB_OUTPUT" + + credentials_path="${GOOGLE_APPLICATION_CREDENTIALS:-}" + if [[ -z "$credentials_path" ]]; then + echo "gcp_credentials_available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ ! -f "$credentials_path" ]]; then + echo "::error title=GCP credentials unavailable::GOOGLE_APPLICATION_CREDENTIALS does not point to a readable file." + exit 1 + fi + + echo "gcp_credentials_available=true" >> "$GITHUB_OUTPUT" + echo "credentials_path=$credentials_path" >> "$GITHUB_OUTPUT" # ========================================================================= - # 15. Docker login and pull R2A image + # 16. Docker login and pull R2A image # ========================================================================= - name: Docker login and pull R2A image if: steps.safety.outputs.should_deploy == 'true' @@ -513,7 +549,7 @@ runs: echo "✓ Image pulled successfully" # ========================================================================= - # 16. Run Rabbit Automation Action (Terraform engine) + # 17. Run Rabbit Automation Action (Terraform engine) # ========================================================================= - name: Run Rabbit Automation Action id: terraform @@ -537,6 +573,11 @@ runs: DOCKERHUB_USERNAME: ${{ inputs.dockerhub_username }} DOCKERHUB_TOKEN: ${{ inputs.dockerhub_helm_token || inputs.dockerhub_token }} SHARED_PROJECT: ${{ inputs.shared_project }} + STATE_BACKEND: ${{ inputs.state_backend }} + STATE_BACKEND_CONFIG: ${{ inputs.state_backend_config }} + STATE_PREFIX_KEY: ${{ inputs.state_prefix_key }} + GCP_CREDENTIALS_AVAILABLE: ${{ steps.prepare-creds.outputs.gcp_credentials_available }} + GCP_CREDENTIALS_PATH: ${{ steps.prepare-creds.outputs.credentials_path }} REPO_OWNER: ${{ github.repository_owner }} REPO_NAME: ${{ github.event.repository.name }} run: | @@ -544,18 +585,22 @@ runs: IMAGE="usabilitydynamics/rabbit-automation-action:${R2A_VERSION}" ARTIFACTS_PATH="terraform/plans" - # Build AWS env vars (may be empty if AWS not configured) + # Forward AWS credentials configured by the action or a preceding workflow step. aws_env=() if [[ -n "${AWS_ACCESS_KEY_ID:-}" ]]; then aws_env+=(-e "AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}") - aws_env+=(-e "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}") - aws_env+=(-e "AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN}") + aws_env+=(-e "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}") + aws_env+=(-e "AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}") aws_env+=(-e "AWS_REGION=${AWS_REGION:-}") - else - aws_env+=(-e "AWS_ACCESS_KEY_ID=") - aws_env+=(-e "AWS_SECRET_ACCESS_KEY=") - aws_env+=(-e "AWS_SESSION_TOKEN=") - aws_env+=(-e "AWS_REGION=") + fi + + gcp_mount=() + if [[ "$GCP_CREDENTIALS_AVAILABLE" == "true" ]]; then + if [[ ! -f "$GCP_CREDENTIALS_PATH" ]]; then + echo "::error title=GCP credentials unavailable::Resolved credentials file is no longer readable." + exit 1 + fi + gcp_mount=(-v "$GCP_CREDENTIALS_PATH:/tmp/gcp-credentials.json:ro" -e "GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp-credentials.json") fi echo "🐰 Running Rabbit Automation Action..." @@ -580,7 +625,10 @@ runs: -e "DOCKERHUB_USERNAME=${DOCKERHUB_USERNAME:-}" \ -e "DOCKERHUB_TOKEN=${DOCKERHUB_TOKEN:-}" \ -e "SHARED_PROJECT=${SHARED_PROJECT:-}" \ - -e "GOOGLE_APPLICATION_CREDENTIALS=/github/workspace/gcp-credentials.json" \ + -e "STATE_BACKEND=${STATE_BACKEND:-}" \ + -e "STATE_BACKEND_CONFIG=${STATE_BACKEND_CONFIG:-}" \ + -e "STATE_PREFIX_KEY=${STATE_PREFIX_KEY:-}" \ + ${gcp_mount[@]+"${gcp_mount[@]}"} \ "${aws_env[@]}" \ "$IMAGE" \ /usr/local/bin/entrypoint.sh @@ -607,19 +655,19 @@ runs: echo "aws_cloudfront_distribution_id=${cloudfront_distribution_id}" >> "$GITHUB_OUTPUT" # ========================================================================= - # 17. Upload terraform artifacts + # 18. Upload terraform artifacts # ========================================================================= - name: Upload terraform artifacts if: steps.safety.outputs.should_deploy == 'true' continue-on-error: true - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: terraform-artifacts-${{ steps.resolve-env.outputs.environment }} path: terraform/plans if-no-files-found: warn # ========================================================================= - # 18. Render plan summary + # 19. Render plan summary # ========================================================================= - name: Render plan summary id: plan-summary @@ -668,7 +716,7 @@ runs: bash "${GITHUB_ACTION_PATH}/bin/render-plan-summary.sh" "$summary_file" "$ARTIFACTS_PATH/plan-summary.md" # ========================================================================= - # 19. Write deployment summary + # 20. Write deployment summary # ========================================================================= - name: Write deployment summary if: always() && steps.safety.outputs.should_deploy == 'true' @@ -712,7 +760,7 @@ runs: fi # ========================================================================= - # 20. Post PR comment with plan summary + # 21. Post PR comment with plan summary # ========================================================================= - name: Post PR comment with plan summary if: github.event_name == 'pull_request' && steps.plan-summary.outputs.has_changes == 'true' @@ -773,19 +821,19 @@ runs: } # ========================================================================= - # 21. Upload terraform plan files + # 22. Upload terraform plan files # ========================================================================= - name: Upload terraform plans if: steps.plan-summary.outputs.has_changes == 'true' continue-on-error: true - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: terraform-plans-${{ steps.resolve-env.outputs.environment }} path: terraform/plans/*.tfplan if-no-files-found: ignore # ========================================================================= - # 22. Send Slack notification + # 23. Send Slack notification # ========================================================================= - name: Send Slack notification if: always() && inputs.slack_webhook != '' && (steps.plan-summary.outputs.has_changes == 'true' || steps.terraform.outcome == 'failure' || steps.safety.outcome == 'failure') diff --git a/tests/run-merge-tests.sh b/tests/run-merge-tests.sh index 7d0a879..d320e16 100755 --- a/tests/run-merge-tests.sh +++ b/tests/run-merge-tests.sh @@ -101,6 +101,13 @@ ROOT="$TEMP_DIR/workspace" SOURCE="$ROOT/.rabbit" INFRA_SOURCE="$ROOT/.rabbit/infra_configs" +scenario "Action metadata supports compatible credential and state-backend configuration" +assert_eq "$(yq -r '.inputs.gcp_auth_provider.required' "$PROJECT_ROOT/action.yml")" "false" "GCP provider input is optional" +assert_eq "$(yq -r '.inputs.gcp_service_account.required' "$PROJECT_ROOT/action.yml")" "false" "GCP service account input is optional" +assert_eq "$(yq -r '.inputs.state_backend.required' "$PROJECT_ROOT/action.yml")" "false" "State backend input is optional" +assert_eq "$(yq -r '.runs.steps[] | select(.name == "Upload terraform artifacts") | .uses' "$PROJECT_ROOT/action.yml")" "actions/upload-artifact@v6" "Artifact upload uses Node.js 24 action runtime" +assert_eq "$(yq -r '.runs.steps[] | select(.name == "Upload terraform plans") | .uses' "$PROJECT_ROOT/action.yml")" "actions/upload-artifact@v6" "Plan upload uses Node.js 24 action runtime" + write_yaml "$SOURCE/production/10-base.yaml" 'services: - module: test-module id: app From 18d1b4d0c22b30f94937c22bcfe00d8a4213c15a Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 29 Jul 2026 17:41:27 +0300 Subject: [PATCH 7/8] Update checkout action runtime --- .github/workflows/ci.yml | 2 +- .rabbit/context.yaml | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c629a3..2c41dc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install pinned yq shell: bash diff --git a/.rabbit/context.yaml b/.rabbit/context.yaml index fedfee5..4828734 100644 --- a/.rabbit/context.yaml +++ b/.rabbit/context.yaml @@ -6,7 +6,7 @@ generator: tool: dev.kit repo: https://github.com/udx/dev.kit version: 0.20.1 - generated_at: 2026-07-29T14:39:41Z + generated_at: 2026-07-29T14:41:16Z sources: homepage: https://udx.dev/kit repository: https://github.com/udx/dev.kit diff --git a/README.md b/README.md index b1df8df..46baab6 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: udx/github-rabbit-action@v1 with: From 80bc8e87fb7e9db90cb5f0cb18baa2f4dd356672 Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 29 Jul 2026 18:35:50 +0300 Subject: [PATCH 8/8] Move cloud authentication to caller workflow --- README.md | 36 +++++++++----------- action.yml | 73 +++++++--------------------------------- tests/run-merge-tests.sh | 11 ++++-- 3 files changed, 36 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 46baab6..d78069c 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,20 @@ jobs: steps: - uses: actions/checkout@v5 + - uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ vars.GCP_AUTH_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + + - uses: aws-actions/configure-aws-credentials@v6 + if: vars.AWS_REGION != '' + with: + role-to-assume: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - uses: udx/github-rabbit-action@v1 with: project_id: ${{ vars.GCP_PROJECT_ID }} - gcp_auth_provider: ${{ vars.GCP_AUTH_PROVIDER }} - gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} - aws_region: ${{ vars.AWS_REGION }} - aws_role_arn: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE_ARN }} slack_webhook: ${{ secrets.SLACK_WEBHOOK_ROUTINE }} dockerhub_username: ${{ vars.DOCKERHUB_USER_LOGIN }} dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN_PULL_R2A }} @@ -152,20 +159,13 @@ services: └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ - │ 4. Cloud Auth │ - │ GCP Workload Identity │ - │ AWS OIDC (optional) │ - └─────────────┬──────────────┘ - │ - ┌─────────────▼──────────────┐ - │ 5. Terraform Engine │ - │ Docker: r2a container │ - │ Per-service init/plan/ │ - │ apply in deploy order │ + │ 4. Terraform Engine │ + │ Caller credentials │ + │ passed into R2A │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ - │ 6. Reporting │ + │ 5. Reporting │ │ Plan summary table │ │ PR comment │ │ GitHub step summary │ @@ -459,7 +459,7 @@ The workflow dispatch inputs provide safe manual control: ## Authentication and state ownership -Existing workflows can keep passing `gcp_auth_provider`, `gcp_service_account`, and optional AWS role inputs to this action. To let the workflow own cloud authentication instead, omit both GCP inputs and run the appropriate provider authentication step first. The action forwards the resulting GCP credential file and any configured AWS environment credentials to its container without copying the GCP credential into the workspace. +The caller workflow owns cloud authentication. Authenticate with Google Cloud before invoking the action; the action mounts the resulting `GOOGLE_APPLICATION_CREDENTIALS` file read-only into the R2A container and never copies it into the workspace. Configure AWS credentials in the caller when the configuration uses AWS; the action forwards the resulting AWS session variables to R2A. The optional state-backend inputs are passed through to the IaC engine. Omit them to retain its existing GCS default; provide the backend type, configuration, and state-path key only when the selected backend requires an override. @@ -470,10 +470,6 @@ The optional state-backend inputs are passed through to the IaC engine. Omit the | Input | Required | Default | Description | | --- | --- | --- | --- | | `project_id` | ✅ | — | Project identifier for state and resource operations | -| `gcp_auth_provider` | — | — | GCP Workload Identity Provider; pair with `gcp_service_account`, or omit both after authenticating in the workflow | -| `gcp_service_account` | — | — | GCP service account; pair with `gcp_auth_provider`, or omit both after authenticating in the workflow | -| `aws_role_arn` | — | — | AWS IAM OIDC role ARN | -| `aws_region` | — | — | AWS region | | `dockerhub_username` | — | — | Docker Hub username | | `dockerhub_token` | — | — | Docker Hub pull token | | `dockerhub_helm_token` | — | — | Docker Hub Helm OCI token | diff --git a/action.yml b/action.yml index d161efa..de871fa 100644 --- a/action.yml +++ b/action.yml @@ -11,21 +11,6 @@ inputs: project_id: description: "Project identifier for Terraform state and resource operations" required: true - gcp_auth_provider: - description: "Optional GCP Workload Identity Federation provider; omit with gcp_service_account to use caller-managed credentials" - required: false - gcp_service_account: - description: "Optional GCP service account; omit with gcp_auth_provider to use caller-managed credentials" - required: false - - # Auth - aws_role_arn: - description: "AWS IAM OIDC role ARN for Route53, CloudFront, WAF, ACM" - required: false - aws_region: - description: "AWS region" - required: false - # Docker dockerhub_username: description: "Docker Hub username for pulling R2A image" @@ -414,9 +399,12 @@ runs: R2A_VERSION: ${{ inputs.r2a_version }} MULTI_REPO: ${{ inputs.multi_repo }} LIFECYCLE_POLICY_PATH: ${{ steps.resolve-lifecycle.outputs.lifecycle_policy_path }} - AWS_ROLE_CONFIGURED: ${{ inputs.aws_role_arn != '' && inputs.aws_region != '' }} run: | config_items="$(printf '%s' "${DETECTED_CONFIGS:-none}" | sed "s/#{Environment}/$ENV_NAME/g" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | sed '/^$/d')" + aws_credentials_available=false + if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then + aws_credentials_available=true + fi { echo "## Configuration Summary" @@ -433,7 +421,7 @@ runs: echo "| Lifecycle Policy | \`${LIFECYCLE_POLICY_PATH:-bundled}\` |" echo "| Plan Only | \`${PLAN_ONLY:-true}\` |" echo "| Multi Repo | \`${MULTI_REPO:-false}\` |" - echo "| AWS Role Input | \`${AWS_ROLE_CONFIGURED}\` |" + echo "| AWS runner credentials | \`${aws_credentials_available}\` |" echo "" echo "### Detected Configs" echo "" @@ -455,44 +443,7 @@ runs: } >> "$GITHUB_STEP_SUMMARY" # ========================================================================= - # 11. Validate optional built-in GCP authentication - # ========================================================================= - - name: Validate GCP authentication inputs - if: steps.safety.outputs.should_deploy == 'true' - shell: bash - env: - GCP_AUTH_PROVIDER: ${{ inputs.gcp_auth_provider }} - GCP_SERVICE_ACCOUNT: ${{ inputs.gcp_service_account }} - run: | - set -euo pipefail - - if [[ -n "$GCP_AUTH_PROVIDER" && -z "$GCP_SERVICE_ACCOUNT" ]] || [[ -z "$GCP_AUTH_PROVIDER" && -n "$GCP_SERVICE_ACCOUNT" ]]; then - echo "::error title=GCP authentication inputs incomplete::Provide both gcp_auth_provider and gcp_service_account, or omit both and authenticate before this action." - exit 1 - fi - - # ========================================================================= - # 12. Authenticate with Google Cloud when configured by the caller - # ========================================================================= - - name: Authenticate with Google Cloud - if: steps.safety.outputs.should_deploy == 'true' && inputs.gcp_auth_provider != '' && inputs.gcp_service_account != '' - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ inputs.gcp_auth_provider }} - service_account: ${{ inputs.gcp_service_account }} - - # ========================================================================= - # 13. Configure AWS credentials (optional) - # ========================================================================= - - name: Configure AWS credentials - if: steps.safety.outputs.should_deploy == 'true' && inputs.aws_role_arn != '' && inputs.aws_region != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ inputs.aws_role_arn }} - aws-region: ${{ inputs.aws_region }} - - # ========================================================================= - # 14. Prepare artifacts directory + # 11. Prepare artifacts directory # ========================================================================= - name: Prepare artifacts directory if: steps.safety.outputs.should_deploy == 'true' @@ -502,7 +453,7 @@ runs: chmod -R 777 terraform/plans # ========================================================================= - # 15. Resolve GCP credentials for a read-only Docker mount + # 12. Resolve caller-provided GCP credentials for a read-only Docker mount # ========================================================================= - name: Resolve GCP credentials id: prepare-creds @@ -513,8 +464,8 @@ runs: credentials_path="${GOOGLE_APPLICATION_CREDENTIALS:-}" if [[ -z "$credentials_path" ]]; then - echo "gcp_credentials_available=false" >> "$GITHUB_OUTPUT" - exit 0 + echo "::error title=GCP credentials unavailable::Authenticate with Google Cloud before invoking github-rabbit-action." + exit 1 fi if [[ ! -f "$credentials_path" ]]; then @@ -526,7 +477,7 @@ runs: echo "credentials_path=$credentials_path" >> "$GITHUB_OUTPUT" # ========================================================================= - # 16. Docker login and pull R2A image + # 13. Docker login and pull R2A image # ========================================================================= - name: Docker login and pull R2A image if: steps.safety.outputs.should_deploy == 'true' @@ -549,7 +500,7 @@ runs: echo "✓ Image pulled successfully" # ========================================================================= - # 17. Run Rabbit Automation Action (Terraform engine) + # 14. Run Rabbit Automation Action (Terraform engine) # ========================================================================= - name: Run Rabbit Automation Action id: terraform @@ -585,7 +536,7 @@ runs: IMAGE="usabilitydynamics/rabbit-automation-action:${R2A_VERSION}" ARTIFACTS_PATH="terraform/plans" - # Forward AWS credentials configured by the action or a preceding workflow step. + # Forward AWS credentials configured by the caller workflow. aws_env=() if [[ -n "${AWS_ACCESS_KEY_ID:-}" ]]; then aws_env+=(-e "AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}") diff --git a/tests/run-merge-tests.sh b/tests/run-merge-tests.sh index d320e16..c21a018 100755 --- a/tests/run-merge-tests.sh +++ b/tests/run-merge-tests.sh @@ -101,12 +101,17 @@ ROOT="$TEMP_DIR/workspace" SOURCE="$ROOT/.rabbit" INFRA_SOURCE="$ROOT/.rabbit/infra_configs" -scenario "Action metadata supports compatible credential and state-backend configuration" -assert_eq "$(yq -r '.inputs.gcp_auth_provider.required' "$PROJECT_ROOT/action.yml")" "false" "GCP provider input is optional" -assert_eq "$(yq -r '.inputs.gcp_service_account.required' "$PROJECT_ROOT/action.yml")" "false" "GCP service account input is optional" +scenario "Action requires caller-managed cloud credentials" +assert_eq "$(yq -r '.inputs | has("gcp_auth_provider")' "$PROJECT_ROOT/action.yml")" "false" "GCP provider input is not accepted" +assert_eq "$(yq -r '.inputs | has("gcp_service_account")' "$PROJECT_ROOT/action.yml")" "false" "GCP service account input is not accepted" +assert_eq "$(yq -r '.inputs | has("aws_role_arn")' "$PROJECT_ROOT/action.yml")" "false" "AWS role input is not accepted" +assert_eq "$(yq -r '.inputs | has("aws_region")' "$PROJECT_ROOT/action.yml")" "false" "AWS region input is not accepted" assert_eq "$(yq -r '.inputs.state_backend.required' "$PROJECT_ROOT/action.yml")" "false" "State backend input is optional" assert_eq "$(yq -r '.runs.steps[] | select(.name == "Upload terraform artifacts") | .uses' "$PROJECT_ROOT/action.yml")" "actions/upload-artifact@v6" "Artifact upload uses Node.js 24 action runtime" assert_eq "$(yq -r '.runs.steps[] | select(.name == "Upload terraform plans") | .uses' "$PROJECT_ROOT/action.yml")" "actions/upload-artifact@v6" "Plan upload uses Node.js 24 action runtime" +assert_eq "$(yq -r '[.runs.steps[] | select(.uses == "google-github-actions/auth@v3" or .uses == "aws-actions/configure-aws-credentials@v6")] | length' "$PROJECT_ROOT/action.yml")" "0" "Action does not configure cloud credentials" +assert_eq "$(grep -c 'Authenticate with Google Cloud before invoking github-rabbit-action' "$PROJECT_ROOT/action.yml")" "1" "Action requires caller-provided GCP credentials" +assert_eq "$(grep -c 'AWS credentials configured by the caller workflow' "$PROJECT_ROOT/action.yml")" "1" "Action forwards caller AWS credentials" write_yaml "$SOURCE/production/10-base.yaml" 'services: - module: test-module