diff --git a/.github/labeler.yml b/.github/labeler.yml index 83d472b..84a80b9 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -57,3 +57,8 @@ github-config: - ".github/CODEOWNERS" - ".releaserc.yml" - ".gitignore" + +# Changes to the canonical deployment matrix consumed by gitops-update.yml +deployment-matrix: + - changed-files: + - any-glob-to-any-file: "config/deployment-matrix.yml" diff --git a/.github/labels.yml b/.github/labels.yml index 8035f58..50b29d5 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -95,3 +95,7 @@ - name: validate color: "1d76db" description: Changes to PR validation composite actions (src/validate/) + +- name: deployment-matrix + color: "5319e7" + description: Changes to the canonical deployment matrix (config/deployment-matrix.yml) diff --git a/.github/workflows/gitops-update.yml b/.github/workflows/gitops-update.yml index 5f3acb0..4488965 100644 --- a/.github/workflows/gitops-update.yml +++ b/.github/workflows/gitops-update.yml @@ -16,13 +16,25 @@ on: type: string required: false deploy_in_firmino: - description: 'Deploy to Firmino server' + description: 'Force-off override for Firmino. Set to false to suppress deployment even if the manifest includes this app on Firmino.' type: boolean default: true deploy_in_clotilde: - description: 'Deploy to Clotilde server' + description: 'Force-off override for Clotilde. Set to false to suppress deployment even if the manifest includes this app on Clotilde.' type: boolean default: true + deploy_in_anacleto: + description: 'Force-off override for Anacleto. Set to false to suppress deployment even if the manifest includes this app on Anacleto.' + type: boolean + default: true + deployment_matrix_file: + description: 'Path (within the shared-workflows checkout) to the deployment matrix manifest. Override only if you maintain a fork/alternative manifest.' + type: string + default: 'config/deployment-matrix.yml' + deployment_matrix_ref: + description: 'Git ref of LerianStudio/github-actions-shared-workflows to read the deployment matrix from. Defaults to main (always latest). Override only when testing a branch.' + type: string + default: 'main' artifact_pattern: description: 'Pattern to download artifacts (defaults to "gitops-tags--*" if not provided)' type: string @@ -80,6 +92,11 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKERHUB_IMAGE_PULL_TOKEN }} + # Trust model: this is a `workflow_call` reusable workflow, NOT triggered by + # untrusted PRs. `inputs.gitops_repository` is supplied by trusted caller + # workflows (default: LerianStudio/midaz-firmino-gitops) and the MANAGE_TOKEN + # is required for the subsequent commit/push step. CodeQL flags this as + # `actions/untrusted-checkout` defensively but the call surface is internal-only. - name: Checkout GitOps Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -88,6 +105,19 @@ jobs: path: gitops fetch-depth: 0 + - name: Checkout deployment matrix manifest + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: LerianStudio/github-actions-shared-workflows + ref: ${{ inputs.deployment_matrix_ref }} + path: shared-workflows + sparse-checkout: | + ${{ inputs.deployment_matrix_file }} + sparse-checkout-cone-mode: false + # Read-only sparse checkout of a single manifest file — no credentials + # needed, and we never execute code from this checkout. + persist-credentials: false + - name: Setup application name and paths id: setup shell: bash @@ -101,15 +131,6 @@ jobs: echo "app_name=$APP_NAME" >> "$GITHUB_OUTPUT" echo "Application name: $APP_NAME" - # Determine which servers to deploy to - DEPLOY_FIRMINO="${{ inputs.deploy_in_firmino }}" - DEPLOY_CLOTILDE="${{ inputs.deploy_in_clotilde }}" - - echo "deploy_firmino=$DEPLOY_FIRMINO" >> "$GITHUB_OUTPUT" - echo "deploy_clotilde=$DEPLOY_CLOTILDE" >> "$GITHUB_OUTPUT" - echo "Deploy to Firmino: $DEPLOY_FIRMINO" - echo "Deploy to Clotilde: $DEPLOY_CLOTILDE" - # Generate commit message prefix if not provided if [[ -z "${{ inputs.commit_message_prefix }}" ]]; then COMMIT_PREFIX="$APP_NAME" @@ -140,6 +161,98 @@ jobs: sudo chmod +x /usr/local/bin/yq yq --version + - name: Resolve target clusters from deployment matrix + id: resolve_clusters + shell: bash + env: + APP_NAME: ${{ steps.setup.outputs.app_name }} + MANIFEST: shared-workflows/${{ inputs.deployment_matrix_file }} + DEPLOY_FIRMINO: ${{ inputs.deploy_in_firmino }} + DEPLOY_CLOTILDE: ${{ inputs.deploy_in_clotilde }} + DEPLOY_ANACLETO: ${{ inputs.deploy_in_anacleto }} + run: | + set -euo pipefail + + if [[ ! -f "$MANIFEST" ]]; then + echo "::error::Deployment matrix manifest not found: $MANIFEST" + exit 1 + fi + + echo "Reading deployment matrix: $MANIFEST" + echo "App name: $APP_NAME" + + # Resolve clusters whose `apps:` list contains $APP_NAME. + # Convert manifest to JSON via yq, then query with jq (more portable than yq's + # native env() — and jq is preinstalled on the runner anyway). + # + # NOTE: We use `index($app)` instead of `contains([$app])` because jq's + # contains() does substring matching on strings — "plugin-br-bank-transfer" + # would falsely match "plugin-br-bank-transfer-jd". `index()` is exact equality. + # Output one cluster per line, sorted for determinism. + # Do not swallow yq/jq errors with `|| true` — a corrupt manifest or + # broken query should fail fast, not be confused with "app not registered". + # Pipefail ensures intermediate failures surface; an empty RESOLVED from + # a *successful* query is the legitimate "no matching clusters" case and + # is handled below. + RESOLVED=$(yq -o=json '.' "$MANIFEST" \ + | jq -r --arg app "$APP_NAME" \ + '.clusters | to_entries[] | select(.value.apps // [] | index($app)) | .key' \ + | sort -u) + + if [[ -z "$RESOLVED" ]]; then + echo "::warning::App '$APP_NAME' is not registered in any cluster of the deployment matrix." + echo "If this is a new app, add it to $MANIFEST in shared-workflows." + echo "clusters=" >> "$GITHUB_OUTPUT" + echo "has_clusters=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Manifest resolution for '$APP_NAME':" + while IFS= read -r line; do + echo " - $line" + done <<< "$RESOLVED" + + # Apply force-off overrides from inputs. + # An input set to "false" removes that cluster from the resolved set, + # even if the manifest includes the app on it. + FILTERED="" + while IFS= read -r cluster; do + [[ -z "$cluster" ]] && continue + case "$cluster" in + firmino) + if [[ "$DEPLOY_FIRMINO" == "false" ]]; then + echo " ✗ firmino — suppressed by deploy_in_firmino=false" + continue + fi + ;; + clotilde) + if [[ "$DEPLOY_CLOTILDE" == "false" ]]; then + echo " ✗ clotilde — suppressed by deploy_in_clotilde=false" + continue + fi + ;; + anacleto) + if [[ "$DEPLOY_ANACLETO" == "false" ]]; then + echo " ✗ anacleto — suppressed by deploy_in_anacleto=false" + continue + fi + ;; + esac + FILTERED="${FILTERED:+$FILTERED }$cluster" + done <<< "$RESOLVED" + + if [[ -z "$FILTERED" ]]; then + echo "::warning::All clusters resolved from the manifest were suppressed by force-off inputs." + echo "clusters=" >> "$GITHUB_OUTPUT" + echo "has_clusters=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "" + echo "Final cluster set: $FILTERED" + echo "clusters=$FILTERED" >> "$GITHUB_OUTPUT" + echo "has_clusters=true" >> "$GITHUB_OUTPUT" + - name: Git pull before update shell: bash run: | @@ -205,50 +318,47 @@ jobs: - name: Apply tags to values.yaml (multi-server) id: apply_tags shell: bash + env: + # Pass through env to avoid `${{ ... }}` interpolation directly into the + # script body (CodeQL: actions/code-injection). IS_* are inherited from job env. + APP_NAME: ${{ steps.setup.outputs.app_name }} + HAS_CLUSTERS: ${{ steps.resolve_clusters.outputs.has_clusters }} + RESOLVED_SERVERS: ${{ steps.resolve_clusters.outputs.clusters }} + MAPPINGS: ${{ inputs.yaml_key_mappings }} + CONFIGMAP_MAPPINGS: ${{ inputs.configmap_updates }} run: | set -euo pipefail - # Get app name - APP_NAME="${{ steps.setup.outputs.app_name }}" - - # Determine environments to update based on tag type - if [[ "${{ env.IS_BETA }}" == "true" ]]; then + # Determine environments to update based on tag type (IS_* from job env) + if [[ "$IS_BETA" == "true" ]]; then ENVIRONMENTS="dev" ENV_LABEL="beta/dev" - elif [[ "${{ env.IS_RC }}" == "true" ]]; then + elif [[ "$IS_RC" == "true" ]]; then ENVIRONMENTS="stg" ENV_LABEL="rc/stg" - elif [[ "${{ env.IS_PRODUCTION }}" == "true" ]]; then + elif [[ "$IS_PRODUCTION" == "true" ]]; then ENVIRONMENTS="dev stg prd sandbox" ENV_LABEL="production" - elif [[ "${{ env.IS_SANDBOX }}" == "true" ]]; then + elif [[ "$IS_SANDBOX" == "true" ]]; then ENVIRONMENTS="sandbox" ENV_LABEL="sandbox" else - echo "Unable to detect environment from tag: ${{ github.ref }}" + echo "Unable to detect environment from tag: ${GITHUB_REF}" exit 1 fi echo "env_label=$ENV_LABEL" >> "$GITHUB_OUTPUT" echo "Detected tag type: $ENV_LABEL" echo "Environments to update: $ENVIRONMENTS" - # Determine servers to deploy to - SERVERS="" - if [[ "${{ steps.setup.outputs.deploy_firmino }}" == "true" ]]; then - SERVERS="firmino" - fi - if [[ "${{ steps.setup.outputs.deploy_clotilde }}" == "true" ]]; then - if [[ -n "$SERVERS" ]]; then - SERVERS="$SERVERS clotilde" - else - SERVERS="clotilde" - fi - fi - - if [[ -z "$SERVERS" ]]; then - echo "No servers selected for deployment. Enable deploy_in_firmino or deploy_in_clotilde." - exit 1 + # Servers come from the deployment matrix manifest, filtered by force-off inputs. + # See step `resolve_clusters` for resolution logic. + if [[ "$HAS_CLUSTERS" != "true" ]]; then + echo "No clusters selected for deployment (manifest empty for this app, or all clusters suppressed)." + echo "sync_matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_sync_targets=false" >> "$GITHUB_OUTPUT" + exit 0 fi + SERVERS="$RESOLVED_SERVERS" echo "Servers to deploy to: $SERVERS" # First, check which artifacts actually exist @@ -314,7 +424,7 @@ jobs: FILE_CHANGED=false # Apply mappings from inputs - only if artifact exists - MAPPINGS='${{ inputs.yaml_key_mappings }}' + # MAPPINGS is set in step env: (avoids code-injection from inputs) while IFS='|' read -r artifact_key yaml_key; do ARTIFACT_FILE=".gitops-tags/${artifact_key}" if [[ -f "$ARTIFACT_FILE" ]]; then @@ -336,8 +446,8 @@ jobs: done < <(echo "$MAPPINGS" | jq -r 'to_entries[] | "\(.key)|\(.value)"') # Apply configmap updates if configured - only if artifact exists - if [[ -n "${{ inputs.configmap_updates }}" ]]; then - CONFIGMAP_MAPPINGS='${{ inputs.configmap_updates }}' + # CONFIGMAP_MAPPINGS is set in step env: (avoids code-injection from inputs) + if [[ -n "$CONFIGMAP_MAPPINGS" ]]; then while IFS='|' read -r artifact_key configmap_key; do ARTIFACT_FILE=".gitops-tags/${artifact_key}" if [[ -f "$ARTIFACT_FILE" ]]; then @@ -475,14 +585,54 @@ jobs: matrix: target: ${{ fromJson(needs.update_gitops.outputs.sync_matrix) }} steps: - - name: Execute ArgoCD Sync - uses: LerianStudio/github-actions-argocd-sync@main - with: - app-name: ${{ matrix.target.server }}-${{ needs.update_gitops.outputs.app_name }} - argo-cd-token: ${{ secrets.ARGOCD_GHUSER_TOKEN }} - argo-cd-url: ${{ secrets.ARGOCD_URL }} - env-prefix: ${{ matrix.target.env }} - skip-if-not-exists: 'true' + - name: Install ArgoCD CLI + shell: bash + run: | + set -euo pipefail + curl -sSL -o /tmp/argocd https://github.com/argoproj/argo-cd/releases/download/v3.0.6/argocd-linux-amd64 + sudo install -m 755 /tmp/argocd /usr/local/bin/argocd + argocd version --client + + - name: Sync ArgoCD application + shell: bash + env: + ARGOCD_URL: ${{ secrets.ARGOCD_URL }} + ARGOCD_TOKEN: ${{ secrets.ARGOCD_GHUSER_TOKEN }} + APP_NAME: ${{ matrix.target.server }}-${{ needs.update_gitops.outputs.app_name }}-${{ matrix.target.env }} + run: | + set -uo pipefail + + echo "::group::argocd app get $APP_NAME" + if ! argocd app get "$APP_NAME" --server "$ARGOCD_URL" --auth-token "$ARGOCD_TOKEN" --grpc-web; then + rc=$? + echo "::endgroup::" + echo "::warning::Failed to get ArgoCD app '$APP_NAME' (exit $rc). Skipping sync — check whether the app exists, auth is valid, and the user has 'applications, get' permission." + exit 0 + fi + echo "::endgroup::" + + echo "::group::argocd app sync $APP_NAME" + for attempt in 1 2 3 4 5; do + if argocd app sync "$APP_NAME" --server "$ARGOCD_URL" --auth-token "$ARGOCD_TOKEN" --grpc-web; then + break + fi + if [[ "$attempt" == "5" ]]; then + echo "::endgroup::" + echo "::error::Sync failed for $APP_NAME after 5 attempts" + exit 1 + fi + echo "Sync attempt $attempt failed, retrying in 5s..." + sleep 5 + done + echo "::endgroup::" + + echo "::group::argocd app wait $APP_NAME" + if ! argocd app wait "$APP_NAME" --server "$ARGOCD_URL" --auth-token "$ARGOCD_TOKEN" --grpc-web; then + echo "::endgroup::" + echo "::error::Timeout waiting for sync completion of $APP_NAME" + exit 1 + fi + echo "::endgroup::" # Slack notification notify: diff --git a/.github/workflows/self-pr-validation.yml b/.github/workflows/self-pr-validation.yml index 5fb3566..267bdca 100644 --- a/.github/workflows/self-pr-validation.yml +++ b/.github/workflows/self-pr-validation.yml @@ -47,7 +47,7 @@ jobs: all_files: ${{ steps.detect.outputs.all-files }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Detect changed files id: detect @@ -63,7 +63,7 @@ jobs: if: needs.changed-files.outputs.yaml_files != '' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: YAML Lint uses: ./src/lint/yamllint @@ -78,7 +78,7 @@ jobs: if: needs.changed-files.outputs.workflow_files != '' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Action Lint uses: ./src/lint/actionlint @@ -93,7 +93,7 @@ jobs: if: needs.changed-files.outputs.action_files != '' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Pinned Actions Check uses: ./src/lint/pinned-actions @@ -108,7 +108,7 @@ jobs: if: needs.changed-files.outputs.markdown_files != '' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Markdown Link Check uses: ./src/lint/markdown-link-check @@ -123,7 +123,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Spelling Check uses: ./src/lint/typos @@ -138,7 +138,7 @@ jobs: if: needs.changed-files.outputs.action_files != '' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Shell Check uses: ./src/lint/shellcheck @@ -153,7 +153,7 @@ jobs: if: needs.changed-files.outputs.action_files != '' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: README Check uses: ./src/lint/readme-check @@ -168,13 +168,28 @@ jobs: if: needs.changed-files.outputs.composite_files != '' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Composite Schema Lint uses: ./src/lint/composite-schema with: files: ${{ needs.changed-files.outputs.composite_files }} + # ----------------- Deployment Matrix Lint ----------------- + deployment-matrix: + name: Deployment Matrix Lint + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: changed-files + if: contains(needs.changed-files.outputs.all_files, 'config/deployment-matrix.yml') + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Deployment Matrix Lint + uses: ./src/lint/deployment-matrix + with: + manifest-file: config/deployment-matrix.yml + # ----------------- CodeQL Analysis ----------------- codeql: name: CodeQL Analysis @@ -188,7 +203,7 @@ jobs: actions: read steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Generate CodeQL config for changed files id: codeql-config @@ -227,11 +242,11 @@ jobs: pull-requests: write issues: write checks: read - needs: [changed-files, yamllint, actionlint, pinned-actions, markdown-link-check, typos, shellcheck, readme-check, composite-schema] + needs: [changed-files, yamllint, actionlint, pinned-actions, markdown-link-check, typos, shellcheck, readme-check, composite-schema, deployment-matrix] if: always() && github.event_name == 'pull_request' && needs.changed-files.result == 'success' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Post Lint Report uses: ./src/notify/pr-lint-reporter @@ -253,3 +268,5 @@ jobs: readme-files: ${{ needs.changed-files.outputs.action_files }} composite-schema-result: ${{ needs.composite-schema.result }} composite-schema-files: ${{ needs.changed-files.outputs.composite_files }} + deployment-matrix-result: ${{ needs.deployment-matrix.result }} + deployment-matrix-files: ${{ contains(needs.changed-files.outputs.all_files, 'config/deployment-matrix.yml') && 'config/deployment-matrix.yml' || '' }} diff --git a/.github/workflows/self-release.yml b/.github/workflows/self-release.yml index c87daac..123a388 100644 --- a/.github/workflows/self-release.yml +++ b/.github/workflows/self-release.yml @@ -14,6 +14,9 @@ on: - '*.md' - '**/*.txt' - '*.txt' + # The deployment matrix is resolved from main at runtime by callers, so + # matrix-only changes propagate without a new release tag. + - 'config/deployment-matrix.yml' tags-ignore: - '**' diff --git a/config/deployment-matrix.yml b/config/deployment-matrix.yml new file mode 100644 index 0000000..059fa8c --- /dev/null +++ b/config/deployment-matrix.yml @@ -0,0 +1,109 @@ +# Deployment Matrix +# +# Source of truth for which apps deploy to which Kubernetes clusters via the +# `gitops-update.yml` reusable workflow. +# +# Cluster-centric layout — mirrors how clusters are operated (lifecycle, +# capacity, incident response). To answer "what runs on cluster X?", read +# the cluster block. To answer "where does app X deploy?", run the helper +# script described in docs/gitops-update-workflow.md (or grep across blocks). +# +# Schema: +# version integer, required, currently 1 +# apps.registry list of every app name allowed to appear in any cluster +# clusters. cluster block with explicit `apps:` list (no `all_apps` +# shortcut today — every cluster has its own subset) +# +# Resolution at runtime: +# 1. Workflow reads this file at the pinned ref of the shared-workflows repo. +# 2. For the caller's `app_name`, the loader collects every cluster whose +# `apps:` list contains it. +# 3. The `deploy_in_` workflow inputs act as **force-off** overrides: +# setting any of them to `false` removes that cluster from the resolved +# set even if the manifest includes the app. +# +# Out of scope (managed manually or by other tooling — do NOT add here): +# - underwriter, jd-mock-api, mock-btg-server, control-plane, +# platform-console (no CI commit history in gitops repo) +# - ledger, dockerhub-secret (no values.yaml — kustomize / k8s Secret) +# - hortencia-apps (legacy umbrella, not workflow-managed) +# - environments/production/* (platform infra: addons, observability) + +version: 1 + +apps: + registry: + # Core platform + - midaz + - fetcher + - flowker + - matcher + - reporter + - tracer + - product-console + + # Plugins + - plugin-access-manager # caller repo may push as plugin-identity / plugin-auth / casdoor + - plugin-fees + - plugin-br-pix-direct-jd + - plugin-br-pix-indirect-btg + - plugin-br-bank-transfer # generic build (firmino + clotilde) + - plugin-br-bank-transfer-jd # JD-specific build (anacleto only) + + # Clotilde-exclusive Lerian platform suite + - backoffice-console + - cs-platform + - forge + - lerian-map + - tenant-manager + +clusters: + firmino: + apps: + - midaz + - fetcher + - flowker + - matcher + - reporter + - tracer + - product-console + - plugin-access-manager + - plugin-fees + - plugin-br-pix-direct-jd + - plugin-br-pix-indirect-btg + - plugin-br-bank-transfer + + clotilde: + apps: + - midaz + - fetcher + - flowker + - matcher + - reporter + - tracer + - product-console + - plugin-access-manager + - plugin-fees + - plugin-br-pix-direct-jd + - plugin-br-pix-indirect-btg + - plugin-br-bank-transfer + - backoffice-console + - cs-platform + - forge + - lerian-map + - tenant-manager + + anacleto: + apps: + - midaz + - fetcher + - flowker + - matcher + - reporter + - tracer + - product-console + - plugin-access-manager + - plugin-fees + - plugin-br-pix-direct-jd + - plugin-br-pix-indirect-btg + - plugin-br-bank-transfer-jd diff --git a/docs/gitops-update-workflow.md b/docs/gitops-update-workflow.md index 80d5d6c..4c0cc33 100644 --- a/docs/gitops-update-workflow.md +++ b/docs/gitops-update-workflow.md @@ -4,7 +4,9 @@ Reusable workflow for updating GitOps repository with new image tags across mult ## Features -- **Multi-server deployment**: Deploy to Firmino and/or Clotilde servers with dynamic path generation +- **Manifest-driven topology**: Cluster membership per app is declared in [`config/deployment-matrix.yml`](../config/deployment-matrix.yml) — no caller-side configuration required to add a cluster to an existing app +- **Multi-server deployment**: Deploy to Firmino, Clotilde and/or Anacleto with dynamic path generation +- **Force-off overrides**: `deploy_in_` inputs can suppress a cluster declared in the manifest, useful for emergency containment without editing the manifest - **Convention-based configuration**: Auto-generates paths, names, and patterns from repository name - **Multi-environment support**: dev (beta), stg (rc), prd (production), sandbox - **Production sync**: Production releases automatically update all environments (dev, stg, prd, sandbox) on all servers @@ -18,7 +20,7 @@ Reusable workflow for updating GitOps repository with new image tags across mult ## Usage -### Minimal Example (Convention-Based, Both Servers) +### Minimal Example (Manifest-Driven) ```yaml update_gitops: @@ -32,30 +34,19 @@ update_gitops: > **Required Secrets**: `MANAGE_TOKEN`, `LERIAN_CI_CD_USER_NAME`, `LERIAN_CI_CD_USER_EMAIL`, `ARGOCD_GHUSER_TOKEN`, `ARGOCD_URL`, `DOCKER_USERNAME`, `DOCKER_PASSWORD` +The workflow reads `config/deployment-matrix.yml` (in the shared-workflows repo at the same pinned ref as the workflow itself) and resolves the cluster set automatically based on `app_name`. No `deploy_in_*` inputs are required for the common case. + **Auto-generated values** (for repo `my-backend-service`): -- App name: `my-backend-service` +- App name: `my-backend-service` (must be present in the deployment matrix) - Artifact pattern: `gitops-tags-my-backend-service-*` -- GitOps paths: - - Firmino: `gitops/environments/firmino/helmfile/applications/{env}/my-backend-service/values.yaml` - - Clotilde: `gitops/environments/clotilde/helmfile/applications/{env}/my-backend-service/values.yaml` -- ArgoCD apps: `firmino-my-backend-service-{env}`, `clotilde-my-backend-service-{env}` +- GitOps paths (one per cluster declared in the manifest): + - `gitops/environments//helmfile/applications/{env}/my-backend-service/values.yaml` +- ArgoCD apps: `-my-backend-service-{env}` for every resolved cluster - Commit prefix: `my-backend-service` -### Single Server Example (Firmino Only) - -```yaml -update_gitops: - needs: build_backend - if: needs.build_backend.result == 'success' - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/gitops-update.yml@v1.0.0 - with: - deploy_in_firmino: true - deploy_in_clotilde: false - yaml_key_mappings: '{"backend.tag": ".auth.image.tag"}' - secrets: inherit -``` +### Force-Off Example (Skip Anacleto for One Run) -### Single Server Example (Clotilde Only) +Useful when you need to ship a hotfix to Firmino and Clotilde but skip Anacleto temporarily (e.g., maintenance window) without touching the manifest: ```yaml update_gitops: @@ -63,12 +54,13 @@ update_gitops: if: needs.build_backend.result == 'success' uses: LerianStudio/github-actions-shared-workflows/.github/workflows/gitops-update.yml@v1.0.0 with: - deploy_in_firmino: false - deploy_in_clotilde: true + deploy_in_anacleto: false yaml_key_mappings: '{"backend.tag": ".auth.image.tag"}' secrets: inherit ``` +`deploy_in_` inputs only **subtract** clusters from the resolved set — they cannot add a cluster the manifest does not list. + ### Multi-Component Example (Midaz) ```yaml @@ -98,8 +90,10 @@ update_gitops: |-------|------|---------|-------------| | `gitops_repository` | string | `LerianStudio/midaz-firmino-gitops` | GitOps repository to update | | `app_name` | string | (repo name) | Application name (auto-detected from repository) | -| `deploy_in_firmino` | boolean | `true` | Deploy to Firmino server | -| `deploy_in_clotilde` | boolean | `true` | Deploy to Clotilde server | +| `deploy_in_firmino` | boolean | `true` | Force-off override for Firmino (`false` = subtract from manifest-resolved set) | +| `deploy_in_clotilde` | boolean | `true` | Force-off override for Clotilde (`false` = subtract from manifest-resolved set) | +| `deploy_in_anacleto` | boolean | `true` | Force-off override for Anacleto (`false` = subtract from manifest-resolved set) | +| `deployment_matrix_file` | string | `config/deployment-matrix.yml` | Path to the deployment matrix manifest within the shared-workflows checkout | | `artifact_pattern` | string | `gitops-tags-{app}-*` | Pattern to download artifacts (auto-generated) | | `commit_message_prefix` | string | (repo name) | Prefix for commit message (auto-generated) | | `runner_type` | string | `blacksmith-4vcpu-ubuntu-2404` | GitHub runner type | @@ -135,6 +129,73 @@ update_gitops: | `DOCKER_USERNAME` | Docker Hub username (to avoid rate limits) | | `DOCKER_PASSWORD` | Docker Hub password | +## Deployment Matrix + +The workflow's cluster topology is declared in [`config/deployment-matrix.yml`](../config/deployment-matrix.yml) — a single source of truth maintained in this repo. + +### How it works + +1. The caller invokes the workflow at a pinned ref (e.g. `@v1.24.0`). +2. The workflow checks out the deployment matrix **at the same ref** (sparse checkout — only the manifest file). +3. For the caller's `app_name`, the workflow collects every cluster whose `apps:` list contains it. +4. `deploy_in_` inputs are applied as **force-off** overrides on the resolved set. +5. The remaining cluster set drives both the GitOps file updates and the ArgoCD sync matrix. + +### Anatomy of the manifest + +```yaml +version: 1 + +apps: + registry: + - midaz + - plugin-fees + # ... every app that uses this workflow + +clusters: + firmino: + apps: [midaz, plugin-fees, ...] + clotilde: + apps: [midaz, plugin-fees, ...] + anacleto: + apps: [midaz, ...] +``` + +- `apps.registry` is the set of legal app names — typo gate. +- Each `clusters..apps` is an explicit list of which apps this cluster hosts. +- A cluster is added by appending one block. A cluster is removed by deleting it. Affects only this repo — caller workflows are untouched. + +### Adding a new app to a cluster + +1. Open a PR in this repo editing `config/deployment-matrix.yml`: + - Add the app name to `apps.registry` (if new). + - Add the app name to `clusters..apps`. +2. The `deployment-matrix` lint job validates schema, integrity, and duplicates on the PR. +3. Once merged, callers consuming the new ref (via Renovate/Dependabot or manual bump) automatically include the cluster on their next release — zero change required in caller repos. + +### Adding a new cluster + +1. Create `environments//...` in the GitOps repo (with at least the app `values.yaml` files you want to populate). +2. In this repo, add a `clusters.:` block listing the apps that should deploy to it. +3. (Optional) Add a `deploy_in_` input to `gitops-update.yml` if you want callers to be able to force-off the new cluster individually. + +### Force-off semantics + +`deploy_in_` inputs default to `true` and only **subtract** from the manifest-resolved set: + +| Manifest says | Input value | Result | +|---|---|---| +| App included in cluster | `true` (default) | Deploys to cluster | +| App included in cluster | `false` | **Suppressed** — does not deploy | +| App NOT included in cluster | `true` (default) | Does not deploy | +| App NOT included in cluster | `false` | Does not deploy | + +Inputs cannot **add** a cluster that the manifest does not list — that prevents accidental cross-cluster spillover. + +### Apps not in the manifest + +If `app_name` is not found in any cluster, the workflow logs a warning and exits cleanly (no failure). This is the expected behavior for apps managed manually or by other tooling. + ## Multi-Server Path Generation The workflow dynamically generates paths for each server and environment combination: @@ -144,7 +205,7 @@ gitops/environments//helmfile/applications///values.yaml ``` Where: -- ``: `firmino` or `clotilde` (controlled by `deploy_in_firmino` and `deploy_in_clotilde` inputs) +- ``: any cluster resolved from the deployment matrix (current set: `firmino`, `clotilde`, `anacleto`), minus those force-off via `deploy_in_: false` - ``: `dev`, `stg`, `prd`, or `sandbox` (determined by tag type) - ``: from `inputs.app_name` or auto-detected from repository name @@ -168,22 +229,13 @@ This allows for partial deployments where not all server/environment combination ### Example: Production Release -When a production tag (e.g., `v1.2.3`) is pushed with both servers enabled, the workflow will: +When a production tag (e.g., `v1.2.3`) is pushed for an app declared in all three clusters, the workflow will: -1. Generate paths for Firmino: - - `gitops/environments/firmino/helmfile/applications/dev/my-app/values.yaml` - - `gitops/environments/firmino/helmfile/applications/stg/my-app/values.yaml` - - `gitops/environments/firmino/helmfile/applications/prd/my-app/values.yaml` - - `gitops/environments/firmino/helmfile/applications/sandbox/my-app/values.yaml` - -2. Generate paths for Clotilde: - - `gitops/environments/clotilde/helmfile/applications/dev/my-app/values.yaml` - - `gitops/environments/clotilde/helmfile/applications/stg/my-app/values.yaml` - - `gitops/environments/clotilde/helmfile/applications/prd/my-app/values.yaml` - - `gitops/environments/clotilde/helmfile/applications/sandbox/my-app/values.yaml` - -3. Apply tags to all existing files (skip missing ones with warning) -4. Sync ArgoCD apps for each server/environment where files were updated +1. Resolve cluster set from manifest: `firmino`, `clotilde`, `anacleto`. +2. For each cluster, generate paths for every production environment (`dev`, `stg`, `prd`, `sandbox`): + - `gitops/environments//helmfile/applications//my-app/values.yaml` +3. Apply tags to all existing files (skip missing ones with warning). +4. Sync ArgoCD apps for each cluster/environment where files were updated. ## ArgoCD Multi-Server Sync @@ -194,11 +246,9 @@ When `enable_argocd_sync` is `true`, the workflow syncs ArgoCD applications for ArgoCD apps are named using the pattern: `--` Examples: -- `firmino-midaz-dev` -- `firmino-midaz-stg` -- `firmino-midaz-prd` -- `clotilde-midaz-dev` -- `clotilde-midaz-stg` +- `firmino-midaz-dev`, `firmino-midaz-stg`, `firmino-midaz-prd` +- `clotilde-midaz-dev`, `clotilde-midaz-stg`, `clotilde-midaz-sandbox` +- `anacleto-midaz-dev` ### Sync Behavior @@ -268,24 +318,36 @@ update_gitops: ### Key Changes 1. **Removed inputs:** - - `gitops_server` - No longer needed; use `deploy_in_firmino` and `deploy_in_clotilde` instead + - `gitops_server` - No longer needed; cluster topology is declared in the deployment matrix - `gitops_file_dev`, `gitops_file_stg`, `gitops_file_prd`, `gitops_file_sandbox` - Paths are now auto-generated - `argocd_app_name` - Now auto-generated based on server/app/env pattern - `environment_detection`, `manual_environment` - Simplified to automatic detection only -2. **New inputs:** - - `deploy_in_firmino` (default: `true`) - Enable deployment to Firmino server - - `deploy_in_clotilde` (default: `true`) - Enable deployment to Clotilde server +2. **Inputs that became force-off overrides:** + - `deploy_in_firmino`, `deploy_in_clotilde`, `deploy_in_anacleto` (all default `true`) — only **subtract** clusters from the manifest-resolved set; cannot add a cluster the manifest does not list + +3. **New inputs:** + - `deployment_matrix_file` (default: `config/deployment-matrix.yml`) — alternative manifest path for forks/testing -3. **Path generation:** - - Paths are automatically generated based on server and environment - - Pattern: `gitops/environments//helmfile/applications///values.yaml` +4. **Path generation:** + - Paths are automatically generated based on cluster (from manifest) and environment (from tag) + - Pattern: `gitops/environments//helmfile/applications///values.yaml` -4. **ArgoCD sync:** - - Now syncs apps for each server/environment combination where files were updated - - Pattern: `--` +5. **ArgoCD sync:** + - Syncs apps for each cluster/environment combination where files were updated + - Pattern: `--` - Checks if app exists before attempting sync +### Migrating an existing caller to manifest-driven topology + +> ⚠️ **Semantic change to `deploy_in_*` inputs** — callers that previously relied on `deploy_in_firmino: true` (etc.) to **include** a cluster will now silently deploy nowhere if their app is not listed in the manifest. The inputs only **subtract** from the manifest-resolved set; they never add. The prerequisite for any deployment is a manifest entry. Workflow logs a warning when `app_name` is missing from every cluster, so these cases surface quickly — but add your app to the manifest before merging this bump if you haven't already. + +If your caller currently passes `deploy_in_firmino: true, deploy_in_clotilde: true` explicitly: + +1. Add your `app_name` to `apps.registry` and to the appropriate `clusters..apps` lists in [`config/deployment-matrix.yml`](../config/deployment-matrix.yml) (single PR in this repo). +2. Once merged and the caller bumps to the new shared-workflows ref (Renovate/Dependabot), the explicit `deploy_in_*: true` inputs become redundant and can be removed from the caller. +3. Keep `deploy_in_: false` only where you want to force-off a cluster the manifest declares. + ## Troubleshooting ### No changes to commit @@ -306,6 +368,16 @@ Ensure the artifact pattern matches your uploaded artifacts: - Pattern: `gitops-tags-*` matches `gitops-tags-backend`, `gitops-tags-frontend`, etc. - Check artifact names in the build job +### App is not registered in any cluster of the deployment matrix + +The workflow logs this warning and exits cleanly when `app_name` is missing from the manifest. Either: +- Add the app to `config/deployment-matrix.yml` in this repo (and bump the caller's pinned ref), or +- Confirm the app is intentionally managed outside this workflow (manual edits, kustomize, separate tooling). + +### All clusters resolved from the manifest were suppressed + +You explicitly set every `deploy_in_: false`. Either remove one of the overrides, or confirm this run is intentionally a no-op. + ### YAML key not updated Verify the YAML key path in your mappings: @@ -315,8 +387,10 @@ Verify the YAML key path in your mappings: ## Best Practices -1. **Start with both servers enabled** - the workflow gracefully handles missing files -2. **Use specific artifact patterns** to avoid conflicts -3. **Test with beta tags first** before deploying to production -4. **Monitor ArgoCD sync results** in workflow logs -5. **Keep YAML key mappings simple** and consistent across environments +1. **Add new apps/clusters via the deployment matrix**, not via per-caller `deploy_in_*` flags — single source of truth wins +2. **Reserve `deploy_in_: false`** for emergency containment or temporary suppression, not for permanent topology decisions +3. **Use specific artifact patterns** to avoid conflicts +4. **Test with beta tags first** before deploying to production +5. **Monitor ArgoCD sync results** in workflow logs +6. **Keep YAML key mappings simple** and consistent across environments +7. **Pin via Renovate/Dependabot** so manifest updates propagate automatically as new ref bumps diff --git a/src/lint/deployment-matrix/README.md b/src/lint/deployment-matrix/README.md new file mode 100644 index 0000000..3ba0ed1 --- /dev/null +++ b/src/lint/deployment-matrix/README.md @@ -0,0 +1,53 @@ + + + + + +
Lerian

deployment-matrix

+ +Validate the deployment matrix manifest at `config/deployment-matrix.yml` (or any custom path). This manifest is the source of truth consumed by the `gitops-update.yml` reusable workflow to decide which apps deploy to which Kubernetes clusters. + +Checks performed: + +**Schema** +- `version` is an integer equal to `1` +- `apps.registry` is a list of non-empty strings +- `clusters` is a mapping of `` → cluster spec +- Each `clusters..apps` is a list of non-empty strings + +**Integrity** +- Every app listed in any `clusters..apps` is declared in `apps.registry` (typo gate) +- No duplicates inside `apps.registry` +- No duplicates inside any `clusters..apps` + +**Hygiene (warnings, not errors)** +- Apps in `apps.registry` not referenced by any cluster are flagged — likely pre-onboarding entries, but worth reviewing to avoid dead registrations + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `manifest-file` | Path to the deployment matrix YAML manifest | No | `config/deployment-matrix.yml` | + +## Usage as composite step + +```yaml +jobs: + deployment-matrix: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Deployment Matrix Lint + uses: LerianStudio/github-actions-shared-workflows/src/lint/deployment-matrix@v1.x.x + with: + manifest-file: config/deployment-matrix.yml +``` + +## Required permissions + +```yaml +permissions: + contents: read +``` diff --git a/src/lint/deployment-matrix/action.yml b/src/lint/deployment-matrix/action.yml new file mode 100644 index 0000000..f33f544 --- /dev/null +++ b/src/lint/deployment-matrix/action.yml @@ -0,0 +1,149 @@ +name: Deployment Matrix Lint +description: Validate the deployment matrix manifest schema, app/cluster integrity, and detect duplicates or orphans. + +inputs: + manifest-file: + description: Path to the deployment matrix YAML manifest + required: false + default: "config/deployment-matrix.yml" + +runs: + using: composite + steps: + # ----------------- Setup ----------------- + - name: Install dependencies + shell: bash + run: | + if ! python3 -c "import yaml" 2>/dev/null; then + sudo apt-get install -y --no-install-recommends python3-yaml + fi + + # ----------------- Log ----------------- + - name: Log manifest under analysis + shell: bash + env: + MANIFEST: ${{ inputs.manifest-file }} + run: | + echo "::group::Deployment matrix file" + echo " - $MANIFEST" + echo "::endgroup::" + + # ----------------- Check ----------------- + - name: Validate deployment matrix + shell: bash + env: + MANIFEST: ${{ inputs.manifest-file }} + run: | + python3 - <<'PYEOF' + import os, sys, yaml + + path = os.environ.get('MANIFEST', '') + violations = 0 + warnings = 0 + + def err(msg, line=None): + global violations + loc = f',line={line}' if line else '' + print(f'::error file={path}{loc}::{msg}') + violations += 1 + + def warn(msg): + global warnings + print(f'::warning file={path}::{msg}') + warnings += 1 + + if not path or not os.path.isfile(path): + print(f'::error::Manifest file not found: {path!r}') + sys.exit(1) + + try: + with open(path) as f: + data = yaml.safe_load(f) + except Exception as e: + err(f'Could not parse YAML: {e}') + sys.exit(1) + + if not isinstance(data, dict): + err('Manifest root must be a YAML mapping.') + sys.exit(1) + + # ── version ── + version = data.get('version') + if not isinstance(version, int): + err('"version" must be an integer.') + elif version != 1: + err(f'Unsupported manifest version {version}; this composite only knows version 1.') + + # ── apps.registry ── + apps_block = data.get('apps') + if not isinstance(apps_block, dict): + err('"apps" must be a mapping containing a "registry" list.') + registry = [] + else: + registry = apps_block.get('registry') + if not isinstance(registry, list): + err('"apps.registry" must be a list of app names.') + registry = [] + else: + for i, item in enumerate(registry): + if not isinstance(item, str) or not item.strip(): + err(f'apps.registry[{i}] must be a non-empty string.') + seen = set() + for item in registry: + if isinstance(item, str): + if item in seen: + err(f'Duplicate app in apps.registry: "{item}".') + seen.add(item) + + registry_set = {a for a in registry if isinstance(a, str)} + + # ── clusters ── + clusters = data.get('clusters') + if not isinstance(clusters, dict): + err('"clusters" must be a mapping of → cluster spec.') + clusters = {} + + referenced_apps = set() + + for cluster_name, cluster_spec in clusters.items(): + if not isinstance(cluster_name, str) or not cluster_name.strip(): + err(f'Cluster name must be a non-empty string (got {cluster_name!r}).') + continue + + if not isinstance(cluster_spec, dict): + err(f'clusters.{cluster_name} must be a mapping.') + continue + + cluster_apps = cluster_spec.get('apps') + if not isinstance(cluster_apps, list): + err(f'clusters.{cluster_name}.apps must be a list of app names.') + continue + + seen = set() + for i, app in enumerate(cluster_apps): + if not isinstance(app, str) or not app.strip(): + err(f'clusters.{cluster_name}.apps[{i}] must be a non-empty string.') + continue + if app in seen: + err(f'Duplicate app "{app}" in clusters.{cluster_name}.apps.') + seen.add(app) + if app not in registry_set: + err(f'clusters.{cluster_name}.apps lists "{app}" which is missing from apps.registry.') + referenced_apps.add(app) + + # ── orphans (warning only) ── + orphans = registry_set - referenced_apps + for app in sorted(orphans): + warn(f'App "{app}" is in apps.registry but not referenced by any cluster — pre-onboarding entry?') + + # ── summary ── + if violations > 0: + print(f'::error::Deployment matrix has {violations} violation(s) and {warnings} warning(s).') + sys.exit(1) + + msg = f'Deployment matrix is valid ({len(registry_set)} apps registered, {len(clusters)} clusters defined' + if warnings: + msg += f', {warnings} warning(s)' + msg += ').' + print(msg) + PYEOF diff --git a/src/notify/pr-lint-reporter/README.md b/src/notify/pr-lint-reporter/README.md index acf061e..3e20826 100644 --- a/src/notify/pr-lint-reporter/README.md +++ b/src/notify/pr-lint-reporter/README.md @@ -28,6 +28,8 @@ Posts a formatted lint analysis summary as a PR comment, aggregating results fro | `readme-files` | Comma-separated list of files checked for README presence | No | `` | | `composite-schema-result` | Result of the composite-schema job | No | `skipped` | | `composite-schema-files` | Comma-separated list of action files validated by composite-schema | No | `` | +| `deployment-matrix-result` | Result of the deployment-matrix job | No | `skipped` | +| `deployment-matrix-files` | Comma-separated list of deployment matrix manifest files validated | No | `` | ## Usage as composite step @@ -35,7 +37,7 @@ Posts a formatted lint analysis summary as a PR comment, aggregating results fro jobs: lint-report: runs-on: blacksmith-4vcpu-ubuntu-2404 - needs: [changed-files, yamllint, actionlint, pinned-actions, markdown-link-check, typos, shellcheck, readme-check, composite-schema] + needs: [changed-files, yamllint, actionlint, pinned-actions, markdown-link-check, typos, shellcheck, readme-check, composite-schema, deployment-matrix] if: always() && github.event_name == 'pull_request' && needs.changed-files.result == 'success' steps: - name: Checkout @@ -61,6 +63,8 @@ jobs: readme-files: ${{ needs.changed-files.outputs.action_files }} composite-schema-result: ${{ needs.composite-schema.result }} composite-schema-files: ${{ needs.changed-files.outputs.composite_files }} + deployment-matrix-result: ${{ needs.deployment-matrix.result }} + deployment-matrix-files: ${{ contains(needs.changed-files.outputs.all_files, 'config/deployment-matrix.yml') && 'config/deployment-matrix.yml' || '' }} ``` ## Required permissions diff --git a/src/notify/pr-lint-reporter/action.yml b/src/notify/pr-lint-reporter/action.yml index a2a215e..80a8b0c 100644 --- a/src/notify/pr-lint-reporter/action.yml +++ b/src/notify/pr-lint-reporter/action.yml @@ -69,6 +69,14 @@ inputs: description: Comma-separated list of action files validated by composite-schema required: false default: "" + deployment-matrix-result: + description: Result of the deployment-matrix job (success/failure/skipped/cancelled) + required: false + default: "skipped" + deployment-matrix-files: + description: Comma-separated list of deployment matrix manifest files validated + required: false + default: "" runs: using: composite @@ -85,7 +93,8 @@ runs: const typosFiles = ${{ toJSON(inputs['typos-files']) }}; const shellcheckFiles = ${{ toJSON(inputs['shellcheck-files']) }}; const readmeFiles = ${{ toJSON(inputs['readme-files']) }}; - const compositeSchemaFiles = ${{ toJSON(inputs['composite-schema-files']) }}; + const compositeSchemaFiles = ${{ toJSON(inputs['composite-schema-files']) }}; + const deploymentMatrixFiles = ${{ toJSON(inputs['deployment-matrix-files']) }}; const checks = [ { @@ -137,6 +146,12 @@ runs: result: '${{ inputs.composite-schema-result }}', files: compositeSchemaFiles.trim().split(',').filter(Boolean), }, + { + jobName: 'Deployment Matrix Lint', + label: 'Deployment Matrix', + result: '${{ inputs.deployment-matrix-result }}', + files: deploymentMatrixFiles.trim().split(',').filter(Boolean), + }, ]; const icon = (r) => ({ success: '✅', failure: '❌', skipped: '⏭️' }[r] ?? '⚠️');