Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/actions/release-gate/action.yml
Original file line number Diff line number Diff line change
@@ -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 <state> <description>
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."
19 changes: 19 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
184 changes: 23 additions & 161 deletions .github/workflows/merge-gate.yml
Original file line number Diff line number Diff line change
@@ -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 }}
19 changes: 19 additions & 0 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

19 changes: 19 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Loading