From 06377a82588f5f0582a45e2850a33810a083a9f0 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 1 Sep 2026 14:04:07 +0300 Subject: [PATCH] ci: post Release PR Gate after tests via a shared action Replace the racing merge gate with a composite action that posts a "Release PR Gate" commit status. It runs after each test suite concludes (release-gate jobs in lint, phpunit and playwright, for release/v*/* PRs) and defers while any required suite is still running, so only the last suite posts the verdict and the status never flaps red mid-run. A release PR without the run-tests label is flagged. merge-gate.yml is slimmed to re-evaluate the gate on checkbox and label edits. --- .github/actions/release-gate/action.yml | 96 +++++++++++++ .github/workflows/lint.yml | 19 +++ .github/workflows/merge-gate.yml | 184 +++--------------------- .github/workflows/phpunit.yml | 19 +++ .github/workflows/playwright.yml | 19 +++ 5 files changed, 176 insertions(+), 161 deletions(-) create mode 100644 .github/actions/release-gate/action.yml diff --git a/.github/actions/release-gate/action.yml b/.github/actions/release-gate/action.yml new file mode 100644 index 000000000..0e22591dc --- /dev/null +++ b/.github/actions/release-gate/action.yml @@ -0,0 +1,96 @@ +name: 'Release PR Gate' +description: 'Evaluate a release pull request and post the "Release PR Gate" commit status. Defers (posts nothing) while required checks are still running.' + +inputs: + token: + description: 'Token with statuses:write and pull-request read access.' + required: true + +runs: + using: composite + steps: + - name: Evaluate and post Release PR Gate status + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + REPO: ${{ github.repository }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }} + PR_BODY: ${{ github.event.pull_request.body }} + CONTEXT: 'Release PR Gate' + run: | + set -uo pipefail + + # Only release/v*/* pull requests are gated. Everything else is left alone. + if ! [[ "$HEAD_REF" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?/ ]]; then + echo "::notice::$HEAD_REF is not a release branch; gate not applicable" + exit 0 + fi + + post() { # post + gh api -X POST "repos/$REPO/statuses/$HEAD_SHA" \ + -f state="$1" -f context="$CONTEXT" -f description="$2" >/dev/null + echo "::notice::Release PR Gate -> $1: $2" + } + + # The test suites only run when the run-tests label is present; without it + # there is nothing to gate on, so flag it rather than silently passing. + if ! [[ " $PR_LABELS " == *" run-tests "* ]]; then + post failure "Add the run-tests label so the test suites run." + exit 0 + fi + + # Latest conclusion of each required summary check for the head commit. + runs_json=$(gh api "repos/$REPO/commits/$HEAD_SHA/check-runs" --paginate 2>/dev/null || echo '{}') + state_of() { + printf '%s' "$runs_json" | jq -r --arg n "$1" \ + '[.check_runs[] | select(.name == $n)] | last // {} | "\(.status // "missing")|\(.conclusion // "")"' + } + pending=false; failed=false + for name in "stylelint, eslint, phpcs" "PHPUnit - Test Results Summary" "Playwright - Test Results Summary"; do + s="$(state_of "$name")"; status="${s%%|*}"; conclusion="${s#*|}" + echo "check '$name' -> status=$status conclusion=$conclusion" + [ "$status" != "completed" ] && pending=true + [ "$status" = "completed" ] && [ "$conclusion" != "success" ] && failed=true + done + + # Die early: a required suite is still running. Whichever suite finishes + # last will re-run this and post the verdict, so nothing is posted now. + if [ "$pending" = true ]; then + echo "::notice::Required checks still running; deferring without posting a status" + exit 0 + fi + + if [ "$failed" = true ]; then + post failure "Required CI checks are not all passing." + exit 0 + fi + + # All required checks are green. Validate the release PR itself. + case "$BASE_REF" in + core|core-beta|pro|pro-beta) ;; + *) post failure "Base branch '$BASE_REF' must be core, core-beta, pro or pro-beta."; exit 0 ;; + esac + + # Parse the two "Release verification" checkboxes, fence-aware so text + # inside the changelog code block cannot spoof them. + body_file="$(mktemp)"; printf '%s' "$PR_BODY" > "$body_file" + in_section=0; in_fence=0; checked=0; total=0 + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*\`\`\` ]]; then in_fence=$((1 - in_fence)); continue; fi + if [[ "$line" =~ ^###[[:space:]]+Release[[:space:]]+verification: ]]; then in_section=1; continue; fi + if [[ "$in_section" -eq 1 && "$line" =~ ^### ]] && ! [[ "$line" =~ Release[[:space:]]+verification ]]; then break; fi + if [[ "$in_section" -eq 1 && "$in_fence" -eq 0 ]]; then + if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\[[xX]\] ]]; then total=$((total + 1)); checked=$((checked + 1)); + elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\[[[:space:]]\] ]]; then total=$((total + 1)); fi + fi + done < "$body_file" + + if [ "$total" -ne 2 ] || [ "$checked" -ne 2 ]; then + post failure "Tick both release-verification checkboxes ($checked/$total approved)." + exit 0 + fi + + post success "Required checks green and release verification approved." diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9ef1238a9..9178f0555 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -86,3 +86,22 @@ jobs: - name: Run PHP CS run: npm run lint:php + + # Post the Release PR Gate status once lint has concluded. Runs for release + # PRs only; defers if the other suites are still running (see the action). + release-gate: + needs: [lint] + if: always() && github.event_name == 'pull_request' && startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Checkout gate action + uses: actions/checkout@v4 + with: + sparse-checkout: .github/actions/release-gate + + - name: Release PR gate + uses: ./.github/actions/release-gate + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/merge-gate.yml b/.github/workflows/merge-gate.yml index d769d35d6..471fb849a 100644 --- a/.github/workflows/merge-gate.yml +++ b/.github/workflows/merge-gate.yml @@ -1,170 +1,32 @@ name: "(Pull Request): Merge Gate" +# Re-evaluates the Release PR Gate on pull-request events that change the gate's +# inputs (checkbox edits, label changes). The gate itself defers while the test +# suites are still running, so this never races them; the after-tests verdict is +# posted by the release-gate jobs in the test workflows. + on: pull_request: types: [opened, edited, labeled, unlabeled, synchronize] +permissions: + contents: read + statuses: write + +concurrency: + group: merge-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: - merge-gate: - name: Release PR Gate + release-gate: runs-on: ubuntu-latest steps: - # Check if this is a release branch (release/v*/*). - # If not, pass silently (no-op for regular PRs). - - name: Check if release PR - id: check-release - run: | - head_ref="${{ github.head_ref }}" - if [[ "$head_ref" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?/ ]]; then - echo "is_release=true" >> "$GITHUB_OUTPUT" - else - echo "is_release=false" >> "$GITHUB_OUTPUT" - fi - - # For non-release branches, pass immediately - - name: Pass (non-release PR) - if: steps.check-release.outputs.is_release == 'false' - run: echo "Non-release PR - merge gate not applicable" - - # Everything below applies only to release/v*/* PRs - # Stop here if not a release PR - - name: Exit if not release PR - if: steps.check-release.outputs.is_release == 'false' - run: "exit 0" - - # Check 1: Base branch must be one of {core, core-beta, pro, pro-beta} - - name: Verify base branch - if: steps.check-release.outputs.is_release == 'true' - run: | - base_ref="${{ github.base_ref }}" - if [[ "$base_ref" =~ ^(core|core-beta|pro|pro-beta)$ ]]; then - echo "Base branch '$base_ref' is valid" - else - echo "ERROR: Base branch '$base_ref' is not in {core, core-beta, pro, pro-beta}" - exit 1 - fi - - # Check 2: PR must have both 'changelog' and 'run-tests' labels - - name: Verify required labels - if: steps.check-release.outputs.is_release == 'true' - env: - PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }} - run: | - has_changelog=false - has_run_tests=false - - for label in $PR_LABELS; do - if [[ "$label" == "changelog" ]]; then - has_changelog=true - fi - if [[ "$label" == "run-tests" ]]; then - has_run_tests=true - fi - done - - if [[ "$has_changelog" != "true" ]]; then - echo "ERROR: Missing 'changelog' label" - exit 1 - fi - if [[ "$has_run_tests" != "true" ]]; then - echo "ERROR: Missing 'run-tests' label" - exit 1 - fi - echo "Both 'changelog' and 'run-tests' labels present" - - # Check 3: Both release verification checkboxes must be checked - - name: Verify release verification checkboxes - if: steps.check-release.outputs.is_release == 'true' - env: - PR_BODY: ${{ github.event.pull_request.body }} - run: | - # Create temporary file with PR body - pr_body_file=$(mktemp) - echo "$PR_BODY" > "$pr_body_file" - - # Parse checkboxes using inline script (fence-aware) - # Parse the Release verification checkboxes (fence-aware). - in_section=0 - in_fence=0 - checked_count=0 - checkbox_count=0 - - while IFS= read -r line; do - # Track fence state: ``` toggles fence - if [[ "$line" =~ ^[[:space:]]*\`\`\` ]]; then - in_fence=$((1 - in_fence)) - continue - fi - - # Track when we enter the "### Release verification:" section - if [[ "$line" =~ ^###[[:space:]]+Release[[:space:]]+verification: ]]; then - in_section=1 - continue - fi - - # If we're in the section and see a new ### heading, we've left - if [[ "$in_section" -eq 1 && "$line" =~ ^### ]] && ! [[ "$line" =~ Release[[:space:]]+verification ]]; then - break - fi - - # Process checkbox lines ONLY if in section and NOT in fence - if [[ "$in_section" -eq 1 && "$in_fence" -eq 0 ]]; then - if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\[[xX]\] ]]; then - checkbox_count=$((checkbox_count + 1)) - checked_count=$((checked_count + 1)) - elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\[[[:space:]]\] ]]; then - checkbox_count=$((checkbox_count + 1)) - fi - fi - done < "$pr_body_file" - - rm "$pr_body_file" - - if [[ $checkbox_count -ne 2 ]]; then - echo "ERROR: Expected exactly 2 checkboxes, found $checkbox_count" - exit 1 - fi - if [[ $checked_count -ne 2 ]]; then - echo "ERROR: Expected both checkboxes to be checked, but only $checked_count are checked" - exit 1 - fi - echo "Both release verification checkboxes are checked" - - # Check 4: lint, phpunit, playwright check-runs must all be success - - name: Verify CI check-runs - if: steps.check-release.outputs.is_release == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - repo="${{ github.repository }}" - sha="${{ github.event.pull_request.head.sha }}" - - # Fetch check-runs for the PR head SHA and filter for required checks by their real names - # Real check-run names: "stylelint, eslint, phpcs", "PHPUnit - Test Results Summary", "Playwright - Test Results Summary" - lint_conclusion=$(gh api repos/"$repo"/commits/"$sha"/check-runs \ - --jq '.check_runs[] | select(.name == "stylelint, eslint, phpcs") | .conclusion' \ - 2>/dev/null || echo "") - - phpunit_conclusion=$(gh api repos/"$repo"/commits/"$sha"/check-runs \ - --jq '.check_runs[] | select(.name == "PHPUnit - Test Results Summary") | .conclusion' \ - 2>/dev/null || echo "") - - playwright_conclusion=$(gh api repos/"$repo"/commits/"$sha"/check-runs \ - --jq '.check_runs[] | select(.name == "Playwright - Test Results Summary") | .conclusion' \ - 2>/dev/null || echo "") - - echo "Check-run conclusions:" - echo " stylelint, eslint, phpcs: ${lint_conclusion:-MISSING}" - echo " PHPUnit - Test Results Summary: ${phpunit_conclusion:-MISSING}" - echo " Playwright - Test Results Summary: ${playwright_conclusion:-MISSING}" - - if [[ "$lint_conclusion" != "success" || "$phpunit_conclusion" != "success" || "$playwright_conclusion" != "success" ]]; then - echo "ERROR: Not all required CI checks are successful" - exit 1 - fi - - echo "All required CI checks passed" - - - name: Merge gate passed - if: steps.check-release.outputs.is_release == 'true' - run: echo "Release PR merge gate - ALL CHECKS PASSED" + - name: Checkout gate action + uses: actions/checkout@v4 + with: + sparse-checkout: .github/actions/release-gate + + - name: Release PR gate + uses: ./.github/actions/release-gate + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index e6d550d27..87a473ac9 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -273,3 +273,22 @@ jobs: if: needs.phpunit.result != 'success' run: exit 1 + # Post the Release PR Gate status once the PHPUnit summary has concluded. + # Runs for release PRs only; defers if other required suites are still running. + release-gate: + needs: [test-result] + if: always() && github.event_name == 'pull_request' && startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Checkout gate action + uses: actions/checkout@v4 + with: + sparse-checkout: .github/actions/release-gate + + - name: Release PR gate + uses: ./.github/actions/release-gate + with: + token: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index cf36bdff0..4ae3844a5 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -333,3 +333,22 @@ jobs: - name: Check overall status if: ${{ (needs.playwright-default.result != 'success' && needs.playwright-default.result != 'skipped') || (needs.playwright-file-based-execution.result != 'success' && needs.playwright-file-based-execution.result != 'skipped') }} run: exit 1 + + # Post the Release PR Gate status once the Playwright summary has concluded. + # Runs for release PRs only; defers if other required suites are still running. + release-gate: + needs: [test-result] + if: always() && github.event_name == 'pull_request' && startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Checkout gate action + uses: actions/checkout@v4 + with: + sparse-checkout: .github/actions/release-gate + + - name: Release PR gate + uses: ./.github/actions/release-gate + with: + token: ${{ secrets.GITHUB_TOKEN }}