From 01f086bbac2c74af37d3dbb535f3084902bd61cb Mon Sep 17 00:00:00 2001 From: Dmytro Smirnov Date: Wed, 27 May 2026 17:33:14 +0300 Subject: [PATCH 1/3] 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/3] 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/3] 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`.