Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0 # Required for rebasing
- name: Rebase on dependency PR
uses: ./actions/rebase_on_dependency
- name: Free Disk Space (Ubuntu)
uses: eclipse-score/more-disk-space@v1
with:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/build_and_test_host.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0 # Required for rebasing
- name: Rebase on dependency PR
uses: ./actions/rebase_on_dependency
- name: Free Disk Space (Ubuntu)
uses: eclipse-score/more-disk-space@v1
with:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/build_and_test_qnx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ jobs:
with:
ref: ${{ github.head_ref || github.event.pull_request.head.ref || github.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
fetch-depth: 0 # Required for rebasing
- name: Rebase on dependency PR
uses: ./actions/rebase_on_dependency
- name: Free Disk Space (Ubuntu)
uses: eclipse-score/more-disk-space@v1
with:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/coverage_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0 # Required for rebasing

- name: Rebase on dependency PR
uses: ./actions/rebase_on_dependency

- name: Free Disk Space (Ubuntu)
uses: eclipse-score/more-disk-space@v1
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/dependency_merge_guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

# Workflow that blocks merging a PR until all Depends-On dependency PRs are merged.
# Add this workflow's job name ("dependency_merge_guard") as a required status check
# in your branch protection rules to enforce the merge gate.

name: Dependency Merge Guard
on:
pull_request:
types: [opened, reopened, synchronize, edited]
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request'}}
jobs:
dependency_merge_guard:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2

- name: Check dependency PRs are merged
uses: ./actions/check_dependency_merged
4 changes: 4 additions & 0 deletions .github/workflows/thread_sanitizer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0 # Required for rebasing
- name: Rebase on dependency PR
uses: ./actions/rebase_on_dependency
- name: Free Disk Space (Ubuntu)
uses: eclipse-score/more-disk-space@v1
with:
Expand Down
28 changes: 28 additions & 0 deletions actions/check_dependency_merged/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

load("@rules_python//python:defs.bzl", "py_library", "py_test")

py_library(
name = "check_dependencies_merged_lib",
srcs = ["scripts/check_dependencies_merged.py"],
imports = ["scripts"],
)

py_test(
name = "check_dependencies_merged_py_test",
srcs = ["test/check_dependencies_merged_test.py"],
main = "test/check_dependencies_merged_test.py",
deps = [":check_dependencies_merged_lib"],
)

74 changes: 74 additions & 0 deletions actions/check_dependency_merged/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

name: "Check Dependency Merged"
description: >
Blocks a PR from being merged until all 'Depends-On: #<PR_NUMBER>'
dependency PRs referenced in the PR description are merged.
Use this workflow's job name as a required status check in branch protection.

inputs:
token:
description: "GitHub token with permissions to read PR information"
required: false
default: ${{ github.token }}

outputs:
dependency_prs:
description: "Comma-separated list of PR numbers that this PR depends on"
value: ${{ steps.check.outputs.dependency_prs }}
dependency_count:
description: "Number of dependency PRs found"
value: ${{ steps.check.outputs.dependency_count }}
all_dependencies_merged:
description: "Whether all dependency PRs are already merged ('true' or 'false')"
value: ${{ steps.check.outputs.all_dependencies_merged }}
unmerged_prs:
description: "Comma-separated list of dependency PR numbers that are not yet merged"
value: ${{ steps.check.outputs.unmerged_prs }}

runs:
using: composite
steps:
- name: Check dependency PRs are merged
id: check
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
ACTION_PATH: ${{ github.action_path }}
run: python3 "${ACTION_PATH}/scripts/check_dependencies_merged.py"

- name: Summary
if: always()
shell: bash
env:
DEPENDENCY_PRS: ${{ steps.check.outputs.dependency_prs || '' }}
DEPENDENCY_COUNT: ${{ steps.check.outputs.dependency_count || 0 }}
ALL_DEPS_MERGED: ${{ steps.check.outputs.all_dependencies_merged || 'true' }}
UNMERGED_PRS: ${{ steps.check.outputs.unmerged_prs || '' }}
run: |
echo "### Dependency Merge Guard" >> $GITHUB_STEP_SUMMARY

if [[ -z "$DEPENDENCY_PRS" ]]; then
echo "✅ No \`Depends-On:\` dependencies found. No merge restrictions." >> $GITHUB_STEP_SUMMARY
elif [[ "$ALL_DEPS_MERGED" == "true" ]]; then
echo "✅ All **${DEPENDENCY_COUNT}** dependency PR(s) are merged. Merge is allowed." >> $GITHUB_STEP_SUMMARY
else
echo "🚫 **Merge blocked**: the following dependency PR(s) are not yet merged: ${UNMERGED_PRS}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This PR cannot be merged until all \`Depends-On:\` PRs are merged." >> $GITHUB_STEP_SUMMARY
echo "Once they are merged, re-run this workflow or push a commit to re-trigger." >> $GITHUB_STEP_SUMMARY
fi

175 changes: 175 additions & 0 deletions actions/check_dependency_merged/scripts/check_dependencies_merged.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env python3

# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

"""
check_dependencies_merged.py
Self-contained script that checks whether all 'Depends-On:' dependency PRs
referenced in a PR description are already merged.

Reads EVENT_NAME, PR_NUMBER, GH_TOKEN from environment variables.
Calls 'gh pr view' to fetch PR data.
Writes dependency_prs=, dependency_count=, all_dependencies_merged=, and
unmerged_prs= directly to $GITHUB_OUTPUT.

Exit codes:
0 – no dependencies, or all dependencies are merged
1 – at least one dependency PR is not yet merged (or an error occurred)
"""

import os
import re
import subprocess
import sys


def parse_depends_on(pr_body: str) -> list[str]:
"""Parse Depends-On patterns from PR body text.

Supports formats: "Depends-On: #123", "Depends-On: 123", "Depends-On:#123"
Case insensitive. Returns a list of PR number strings.
"""
if not pr_body:
return []
return re.findall(r"(?i)depends-on:\s*#?(\d+)", pr_body)


def get_pr_body(pr_number: str) -> str:
"""Fetch the PR body text using the GitHub CLI.

Raises:
RuntimeError: if the gh CLI exits with a non-zero return code.
"""
result = subprocess.run(
["gh", "pr", "view", pr_number, "--json", "body", "--jq", ".body"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"gh CLI failed for PR #{pr_number} (exit {result.returncode}): "
f"{result.stderr.strip()}"
)
return result.stdout.strip()


def get_pr_state(pr_number: str) -> str:
"""Get the state of a PR using the GitHub CLI.

Returns one of "OPEN", "CLOSED", "MERGED", or "" on error.
"""
result = subprocess.run(
["gh", "pr", "view", pr_number, "--json", "state", "--jq", ".state"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return ""
return result.stdout.strip()


def check_dependencies(pr_numbers: list[str]) -> tuple[bool, list[str]]:
"""Check whether all dependency PRs are merged.

Returns:
A tuple (all_merged, unmerged_prs) where all_merged is True only if
every dependency PR has state MERGED, and unmerged_prs lists the PR
numbers that are not yet merged.
"""
unmerged: list[str] = []

for pr_number in pr_numbers:
state = get_pr_state(pr_number)
print(f" PR #{pr_number}: {state or 'UNKNOWN'}")

if state != "MERGED":
unmerged.append(pr_number)

return len(unmerged) == 0, unmerged


def write_github_output(
output_path: str,
dependency_prs: list[str],
all_merged: bool,
unmerged_prs: list[str],
) -> None:
"""Write results to the GITHUB_OUTPUT file."""
with open(output_path, "a", encoding="utf-8") as f:
f.write(f"dependency_prs={','.join(dependency_prs)}\n")
f.write(f"dependency_count={len(dependency_prs)}\n")
f.write(f"all_dependencies_merged={'true' if all_merged else 'false'}\n")
f.write(f"unmerged_prs={','.join(unmerged_prs)}\n")


def main() -> int:
event_name = os.environ.get("EVENT_NAME", "")
pr_number = os.environ.get("PR_NUMBER", "")
output_path = os.environ.get("GITHUB_OUTPUT", os.devnull)

if event_name not in ("pull_request", "pull_request_target", "merge_group"):
print("Not a pull request event, skipping dependency merge check.")
write_github_output(output_path, [], True, [])
return 0

if not pr_number:
print("Could not determine PR number.")
write_github_output(output_path, [], True, [])
return 0

print(f"Checking PR #{pr_number} for dependencies...")

try:
pr_body = get_pr_body(pr_number)
except RuntimeError as exc:
print(f"::error::{exc}")
write_github_output(output_path, [], True, [])
return 1

if not pr_body:
print("PR description is empty, no dependencies to check.")
write_github_output(output_path, [], True, [])
return 0

dependency_prs = parse_depends_on(pr_body)

if not dependency_prs:
print("No 'Depends-On:' keyword found in PR description.")
write_github_output(output_path, [], True, [])
return 0

print(
f"Found {len(dependency_prs)} dependency PR(s): "
f"{','.join(dependency_prs)}"
)
print("Checking merge status...")

all_merged, unmerged_prs = check_dependencies(dependency_prs)
write_github_output(output_path, dependency_prs, all_merged, unmerged_prs)

if all_merged:
print(f"✅ All {len(dependency_prs)} dependency PR(s) are merged.")
return 0

print(
f"❌ {len(unmerged_prs)} dependency PR(s) not yet merged: "
f"{', '.join(f'#{pr}' for pr in unmerged_prs)}"
)
print("This PR cannot be merged until all dependency PRs are merged.")
return 1


if __name__ == "__main__":
sys.exit(main())

Loading
Loading