# Copyright (c) 2026 Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
name: PR Validation

on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches:
      - main
      - develop
      - release/prerelease
      - release/stable
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
  cancel-in-progress: true

# Minimal permissions for security
permissions:
  contents: read

jobs:
  spell-check:
    name: Spell Check
    uses: ./.github/workflows/spell-check.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  markdown-lint:
    name: Markdown Lint
    uses: ./.github/workflows/markdown-lint.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  table-format:
    name: Table Format Check
    uses: ./.github/workflows/table-format.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  psscriptanalyzer:
    name: PowerShell Lint
    uses: ./.github/workflows/ps-script-analyzer.yml
    permissions:
      contents: read
    with:
      soft-fail: false
      changed-files-only: true

  discover-python-projects:
    name: Discover Python Projects
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      directories: ${{ steps.find.outputs.directories }}
      has-projects: ${{ steps.find.outputs.has-projects }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Find Python projects
        id: find
        shell: pwsh
        run: |
          # The moderation eval (scripts/evals/moderation) carries a heavy
          # torch/detoxify stack and runs weekly via weekly-validation.yml,
          # so it is excluded from per-PR Python matrix jobs here.
          $projectList = @(Get-ChildItem -Recurse -Force -Filter pyproject.toml |
            Where-Object { $_.FullName -notmatch 'node_modules' -and $_.FullName -notmatch 'evals[\\/]+moderation' } |
            ForEach-Object { Resolve-Path -Relative $_.DirectoryName } |
            ForEach-Object { $_ -replace '^\.[\\/]', '' } |
            Sort-Object)
          # Reject repository-derived paths that could alter command structure downstream.
          . ./scripts/security/Assert-WorkflowProjectDirectory.ps1
          $projectList = @(Assert-WorkflowProjectDirectory -Path $projectList)
          $jsonItems = $projectList | ForEach-Object { $_ | ConvertTo-Json -Compress }
          $dirs = '[' + (($jsonItems) -join ',') + ']'
          "directories=$dirs" >> $env:GITHUB_OUTPUT
          if ($projectList.Count -eq 0) {
            "has-projects=false" >> $env:GITHUB_OUTPUT
            Write-Output 'No Python projects found'
          } else {
            "has-projects=true" >> $env:GITHUB_OUTPUT
            Write-Output "Found Python projects: $dirs"
          }

  discover-node-projects:
    name: Discover Node Skills
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      directories: ${{ steps.find.outputs.directories }}
      has-projects: ${{ steps.find.outputs.has-projects }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Find Node skills
        id: find
        shell: pwsh
        run: |
          # A Node skill is any skill (dir containing SKILL.md) that ships at
          # least one *.test.* / *.spec.* (mjs/cjs/js). The skill root is the
          # node --test working dir.
          $skills = @(Get-ChildItem -Path .github/skills -Recurse -Force -Filter SKILL.md -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName -notmatch 'node_modules' } |
            ForEach-Object { $_.DirectoryName } |
            Where-Object { @(Get-ChildItem -Path $_ -Recurse -Force -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '\.(test|spec)\.(mjs|cjs|js)$' -and $_.FullName -notmatch 'node_modules' }).Count -gt 0 } |
            ForEach-Object { (Resolve-Path -Relative $_) -replace '^\.[\\/]', '' -replace '\\', '/' } |
            Sort-Object -Unique)
          # Reject repository-derived paths that could alter command structure downstream.
          . ./scripts/security/Assert-WorkflowProjectDirectory.ps1
          $skills = @(Assert-WorkflowProjectDirectory -Path $skills)
          $jsonItems = $skills | ForEach-Object { $_ | ConvertTo-Json -Compress }
          $dirs = '[' + (($jsonItems) -join ',') + ']'
          "directories=$dirs" >> $env:GITHUB_OUTPUT
          if ($skills.Count -eq 0) {
            "has-projects=false" >> $env:GITHUB_OUTPUT
            Write-Output 'No Node skills found'
          } else {
            "has-projects=true" >> $env:GITHUB_OUTPUT
            Write-Output "Found Node skills: $dirs"
          }

  python-lint:
    name: "Python Lint (${{ matrix.directory }})"
    needs: discover-python-projects
    if: needs.discover-python-projects.outputs.has-projects == 'true'
    strategy:
      fail-fast: false
      matrix:
        directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
    uses: ./.github/workflows/python-lint.yml
    permissions:
      contents: read
    with:
      soft-fail: false
      changed-files-only: true
      working-directory: ${{ matrix.directory }}

  copyright-headers:
    name: Copyright Headers
    uses: ./.github/workflows/copyright-headers.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  yaml-lint:
    name: YAML Lint
    uses: ./.github/workflows/yaml-lint.yml
    permissions:
      contents: read
    with:
      soft-fail: false
      changed-files-only: true

  pester-tests:
    name: PowerShell Tests
    uses: ./.github/workflows/pester-tests.yml
    permissions:
      contents: read
      id-token: write
    with:
      soft-fail: false
      changed-files-only: false
      code-coverage: true

  pytest:
    name: "Python Tests (${{ matrix.directory }})"
    needs: discover-python-projects
    if: needs.discover-python-projects.outputs.has-projects == 'true'
    uses: ./.github/workflows/pytest-tests.yml
    permissions:
      contents: read
      id-token: write
    with:
      working-directory: ${{ matrix.directory }}
      soft-fail: false
      changed-files-only: true
    strategy:
      fail-fast: false
      matrix:
        directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}

  node-tests:
    name: "Node Tests (${{ matrix.directory }})"
    needs: discover-node-projects
    if: needs.discover-node-projects.outputs.has-projects == 'true'
    uses: ./.github/workflows/node-tests.yml
    permissions:
      contents: read
      id-token: write
    with:
      working-directory: ${{ matrix.directory }}
      soft-fail: false
      changed-files-only: true
    strategy:
      fail-fast: false
      matrix:
        directory: ${{ fromJson(needs.discover-node-projects.outputs.directories) }}

  accessibility-browser-smoke:
    name: Accessibility Browser Smoke
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: "24"

      - name: Install accessibility skill Node dependencies
        shell: bash
        env:
          SEARCH_ROOT: .github/skills/accessibility/accessibility
        run: ./scripts/ci/install-skill-node-deps.sh

      - name: Verify system Chrome
        shell: bash
        run: |
          set -euo pipefail
          chrome_bin="$(command -v google-chrome || command -v google-chrome-stable || true)"
          if [ -z "$chrome_bin" ]; then
            echo "::error::No system Google Chrome found on the runner; accessibility smoke tests require it"
            exit 1
          fi
          echo "Using system Chrome at: $chrome_bin"
          "$chrome_bin" --version

      - name: Run accessibility browser smoke tests
        run: npm run ci:test:a11y:smoke

  fuzz-tests:
    name: "Fuzz Tests (${{ matrix.directory }})"
    needs: discover-python-projects
    if: needs.discover-python-projects.outputs.has-projects == 'true'
    uses: ./.github/workflows/fuzz-tests.yml
    permissions:
      contents: read
    with:
      working-directory: ${{ matrix.directory }}
      soft-fail: false
      changed-files-only: true
    strategy:
      fail-fast: false
      matrix:
        directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}

  pip-audit:
    name: "pip-audit (${{ matrix.directory }})"
    needs: discover-python-projects
    if: needs.discover-python-projects.outputs.has-projects == 'true'
    strategy:
      fail-fast: false
      matrix:
        directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
    uses: ./.github/workflows/pip-audit.yml
    permissions:
      contents: read
    with:
      working-directory: ${{ matrix.directory }}
      soft-fail: false
      changed-files-only: true

  docusaurus-tests:
    name: Docusaurus Tests
    uses: ./.github/workflows/docusaurus-tests.yml
    permissions:
      contents: read
      id-token: write # Required for Codecov OIDC in the reusable workflow
    with:
      soft-fail: false
      changed-files-only: true

  frontmatter-validation:
    name: Frontmatter Validation
    uses: ./.github/workflows/frontmatter-validation.yml
    permissions:
      contents: read
    with:
      soft-fail: false
      changed-files-only: true
      skip-footer-validation: false
      warnings-as-errors: true

  adr-consistency-validation:
    name: ADR Consistency Validation
    uses: ./.github/workflows/adr-consistency-validation.yml
    permissions:
      contents: read
      security-events: write # Required for SARIF upload to Security tab
    with:
      soft-fail: false
      changed-files-only: true
      upload-sarif: true
      upload-artifact: false

  ai-artifact-validation:
    name: AI Artifact Validation
    uses: ./.github/workflows/ai-artifact-validation.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  asset-docs-validation:
    name: Asset Docs Validation
    uses: ./.github/workflows/asset-docs-validation.yml
    permissions:
      contents: read
    with:
      soft-fail: false
      changed-files-only: true
      base-branch: ${{ github.event.pull_request.base.sha || format('origin/{0}', github.base_ref) }}

  msdate-freshness:
    name: ms.date Freshness Check
    uses: ./.github/workflows/msdate-freshness-check.yml
    permissions:
      contents: read
    with:
      staleness-threshold-days: 90
      changed-files-only: true
      soft-fail: false

  plugin-validation:
    name: Plugin Validation
    uses: ./.github/workflows/plugin-validation.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  skill-validation:
    name: Skill Validation
    uses: ./.github/workflows/skill-validation.yml
    permissions:
      contents: read
    with:
      soft-fail: false
      changed-files-only: true
      base-branch: ${{ github.event.pull_request.base.sha || format('origin/{0}', github.base_ref) }}

  eval-validation:
    name: Eval Validation
    uses: ./.github/workflows/eval-validation.yml
    permissions:
      contents: read
      pull-requests: write
    with:
      soft-fail: false
      changed-files-only: true
      base-branch: ${{ github.event.pull_request.base.sha || format('origin/{0}', github.base_ref) }}
    secrets:
      copilot-github-token: ${{ secrets.COPILOT_GITHUB_TOKEN }}

  link-lang-check:
    name: Link Language Check
    uses: ./.github/workflows/link-lang-check.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  markdown-link-check:
    name: Markdown Link Check
    uses: ./.github/workflows/markdown-link-check.yml
    permissions:
      contents: read
    with:
      soft-fail: true

  dependency-pinning-check:
    name: Validate Dependency Pinning
    uses: ./.github/workflows/dependency-pinning-scan.yml
    permissions:
      contents: read
      security-events: write # Required for SARIF upload to Security tab
    with:
      soft-fail: false
      upload-sarif: true
      upload-artifact: false

  devcontainer-lockfile-check:
    name: Devcontainer Lockfile Integrity
    uses: ./.github/workflows/devcontainer-lockfile-check.yml
    permissions:
      contents: read
    with:
      soft-fail: false

  workflow-permissions-check:
    name: Workflow Permissions Check
    uses: ./.github/workflows/workflow-permissions-scan.yml
    permissions:
      contents: read
      security-events: write # Required for SARIF upload to Security tab
    with:
      soft-fail: false
      upload-sarif: true
      upload-artifact: false

  workflow-runner-check:
    name: Workflow Runner Check
    uses: ./.github/workflows/workflow-runner-scan.yml
    permissions:
      contents: read
      security-events: write # Required for SARIF upload to Security tab
    with:
      soft-fail: false
      upload-sarif: true
      upload-artifact: false

  dangerous-workflow-check:
    name: Dangerous Workflow Check
    uses: ./.github/workflows/dangerous-workflow-scan.yml
    permissions:
      contents: read
      security-events: write # Required for SARIF upload to Security tab
    with:
      soft-fail: false
      upload-sarif: true
      upload-artifact: false
      poutine-soft-fail: true # Poutine runs advisory; homegrown template-injection check is the hard gate

  action-version-consistency-scan:
    name: Action Version Consistency Scan
    uses: ./.github/workflows/action-version-consistency-scan.yml
    permissions:
      contents: read
      security-events: write # Required for SARIF upload to Security tab
    with:
      soft-fail: false
      upload-sarif: true
      upload-artifact: false

  gitleaks-scan:
    name: Gitleaks Secret Scan
    uses: ./.github/workflows/gitleaks-scan.yml
    permissions:
      contents: read
      security-events: write # Required for SARIF upload to Security tab
    with:
      soft-fail: false
      upload-sarif: true
      upload-artifact: false
      log-opts: "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"

  npm-audit:
    name: npm Security Audit
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Setup Node.js
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: "24"
          cache: "npm"

      - name: Validate public dependency feeds
        run: npm run lint:public-dependency-feeds

      - name: Install dependencies
        run: npm ci

      # Uses audit-ci with an allowlist for advisories that have no installable
      # upstream fix. See audit-ci.json for the current allowlist.
      - name: Audit all npm projects
        run: npm run audit:npm

  codeql:
    name: CodeQL Security Analysis
    uses: ./.github/workflows/codeql-analysis.yml
    permissions:
      contents: read
      security-events: write # Required for SARIF upload to Security tab
      actions: read

  gate-completeness-check:
    name: PR Gate Completeness
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Setup PowerShell modules
        uses: ./.github/actions/setup-ps-modules

      - name: Validate PR gate completeness
        shell: pwsh
        run: ./scripts/security/Test-PrValidationGate.ps1 -FailOnViolation

      # The promotion head is the last reviewable state before release-please
      # can consume its exact release intent, so grammar, parity, advancement,
      # and the retained candidate record are proved while the pull request is
      # still open. Branch rulesets decide whether this failing check blocks the
      # merge; release-prerelease.yml independently revalidates the same intent
      # before any tag can be created.
      - name: Validate PreRelease promotion intent advances release/prerelease
        if: >
          github.event_name == 'pull_request'
          && github.event.pull_request.head.repo.full_name == github.repository
          && github.event.pull_request.base.ref == 'release/prerelease'
          && github.event.pull_request.head.ref == 'release-promotion--main--to--release-prerelease'
        env:
          HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PROMOTION_HEAD: release-promotion--main--to--release-prerelease
        run: |
          set -euo pipefail
          if [ "$HEAD_BRANCH" != "$PROMOTION_HEAD" ]; then
            echo "::error::PreRelease promotion head $HEAD_BRANCH is not the canonical promotion head"
            exit 1
          fi
          if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
            echo "::error::PreRelease promotion event carries an invalid pull request number"
            exit 1
          fi
          if [[ ! "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
            echo "::error::PreRelease promotion event carries an invalid head SHA"
            exit 1
          fi

          # hve-core is public, so this origin access is intentionally
          # credential free; the fetch and ls-remote below stop working if the
          # repository ever becomes private.

          # The verified commit is the exact event head, so a head that moved
          # after the event fails rather than being proved at a later tip.
          git fetch --no-tags origin \
            "+refs/pull/$PR_NUMBER/head:refs/remotes/pull/$PR_NUMBER/head" \
            "+refs/heads/release/prerelease:refs/remotes/origin/release/prerelease"
          if [ "$(git rev-parse "refs/remotes/pull/$PR_NUMBER/head")" != "$HEAD_SHA" ]; then
            echo "::error::Fetched PreRelease promotion head does not match event head $HEAD_SHA"
            exit 1
          fi

          CANDIDATE=$(git show "refs/remotes/pull/$PR_NUMBER/head:release-please-prerelease-config.json" | jq -r '.packages["."]["release-as"] // ""')
          BASELINE=$(git show 'refs/remotes/origin/release/prerelease:.release-please-prerelease-manifest.json' | jq -r '.["."] // ""')
          for entry in "candidate:$CANDIDATE" "baseline:$BASELINE"; do
            value=${entry#*:}
            if [[ "$value" == *$'\n'* || "$value" == *$'\r'* || ! "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
              echo "::error::${entry%%:*} PreRelease version is not canonical MAJOR.MINOR.PATCH"
              exit 1
            fi
          done
          CANDIDATE_MINOR=${CANDIDATE#*.}
          CANDIDATE_MINOR=${CANDIDATE_MINOR%%.*}
          if (( 10#$CANDIDATE_MINOR % 2 == 0 )); then
            echo "::error::PreRelease candidate $CANDIDATE has an even minor"
            exit 1
          fi
          if [ "$CANDIDATE" = "$BASELINE" ] || [ "$(printf '%s\n%s\n' "$CANDIDATE" "$BASELINE" | sort -V | tail -1)" = "$BASELINE" ]; then
            echo "::error::$HEAD_BRANCH proposes $CANDIDATE but release/prerelease already carries $BASELINE"
            exit 1
          fi

          # A tag is immutable, so a transport failure aborts under set -e
          # instead of reporting the identity as available.
          CANDIDATE_REF="refs/tags/prerelease-v$CANDIDATE"
          OCCUPIED=$(git ls-remote origin "$CANDIDATE_REF")
          if [ -n "$OCCUPIED" ]; then
            echo "::error::$CANDIDATE_REF already exists; the PreRelease release identity is occupied"
            exit 1
          fi

          echo "$HEAD_BRANCH advances release/prerelease from $BASELINE to $CANDIDATE"

      - name: Validate Stable promotion intent advances release/stable
        if: >
          github.event_name == 'pull_request'
          && github.event.pull_request.base.ref == 'release/stable'
          && startsWith(github.head_ref, 'release-promotion--release-prerelease--to--release-stable--prerelease-v')
        env:
          HEAD_BRANCH: ${{ github.head_ref }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          set -euo pipefail
          if [[ ! "$HEAD_BRANCH" =~ ^release-promotion--release-prerelease--to--release-stable--(prerelease-v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
            echo "::error::Stable promotion head $HEAD_BRANCH does not match the canonical selected-tag grammar"
            exit 1
          fi
          SOURCE_TAG=${BASH_REMATCH[1]}
          SOURCE_VERSION=${SOURCE_TAG#prerelease-v}
          SOURCE_MINOR=${SOURCE_VERSION#*.}
          SOURCE_MINOR=${SOURCE_MINOR%%.*}
          if (( 10#$SOURCE_MINOR % 2 == 0 )); then
            echo "::error::Stable promotion source $SOURCE_TAG has an even minor"
            exit 1
          fi
          if [[ ! "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
            echo "::error::Stable promotion event carries an invalid head SHA"
            exit 1
          fi

          git fetch --no-tags origin \
            "+refs/pull/$PR_NUMBER/head:refs/remotes/pull/$PR_NUMBER/head" \
            "+refs/heads/release/stable:refs/remotes/origin/release/stable" \
            "+refs/tags/$SOURCE_TAG:refs/tags/$SOURCE_TAG"
          if [ "$(git rev-parse "refs/remotes/pull/$PR_NUMBER/head")" != "$HEAD_SHA" ]; then
            echo "::error::Fetched Stable promotion head does not match event head $HEAD_SHA"
            exit 1
          fi

          CANDIDATE=$(git show "refs/remotes/pull/$PR_NUMBER/head:release-please-config.json" | jq -r '.packages["."]["release-as"] // ""')
          BASELINE=$(git show 'refs/remotes/origin/release/stable:.release-please-manifest.json' | jq -r '.["."] // ""')
          for entry in "candidate:$CANDIDATE" "baseline:$BASELINE"; do
            value=${entry#*:}
            if [[ "$value" == *$'\n'* || "$value" == *$'\r'* || ! "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
              echo "::error::${entry%%:*} Stable version is not canonical MAJOR.MINOR.PATCH"
              exit 1
            fi
          done
          CANDIDATE_MINOR=${CANDIDATE#*.}
          CANDIDATE_MINOR=${CANDIDATE_MINOR%%.*}
          if (( 10#$CANDIDATE_MINOR % 2 != 0 )); then
            echo "::error::Stable candidate $CANDIDATE has an odd minor"
            exit 1
          fi
          if [ "$CANDIDATE" = "$BASELINE" ] || [ "$(printf '%s\n%s\n' "$CANDIDATE" "$BASELINE" | sort -V | tail -1)" = "$BASELINE" ]; then
            echo "::error::$HEAD_BRANCH proposes $CANDIDATE but release/stable already carries $BASELINE"
            exit 1
          fi

          SOURCE_SHA=$(git rev-parse --verify --end-of-options "refs/tags/$SOURCE_TAG^{commit}")
          if git merge-base --is-ancestor "$SOURCE_SHA" refs/remotes/origin/release/stable; then
            echo "::error::$SOURCE_TAG at $SOURCE_SHA is already contained in release/stable"
            exit 1
          fi
          echo "$HEAD_BRANCH advances release/stable from $BASELINE to $CANDIDATE using $SOURCE_TAG"

  pr-validation-success:
    name: PR Validation Success
    runs-on: ubuntu-latest
    permissions:
      contents: read
    if: always()
    needs:
      - spell-check
      - markdown-lint
      - table-format
      - psscriptanalyzer
      - discover-python-projects
      - python-lint
      - copyright-headers
      - yaml-lint
      - pester-tests
      - pytest
      - discover-node-projects
      - node-tests
      - accessibility-browser-smoke
      - fuzz-tests
      - pip-audit
      - docusaurus-tests
      - frontmatter-validation
      - adr-consistency-validation
      - ai-artifact-validation
      - asset-docs-validation
      - msdate-freshness
      - plugin-validation
      - skill-validation
      - eval-validation
      - link-lang-check
      - markdown-link-check
      - dependency-pinning-check
      - devcontainer-lockfile-check
      - workflow-permissions-check
      - workflow-runner-check
      - dangerous-workflow-check
      - action-version-consistency-scan
      - gitleaks-scan
      - npm-audit
      - codeql
      - gate-completeness-check
    steps:
      - name: Verify all jobs succeeded
        env:
          NEEDS_JSON: ${{ toJSON(needs) }}
        shell: bash
        run: |
          set -euo pipefail
          failed=$(echo "$NEEDS_JSON" | jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "skipped") | .key')
          if [ -n "$failed" ]; then
            echo "The following jobs did not pass:"
            echo "$failed"
            exit 1
          fi
          echo "All PR validation jobs passed."
