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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ name: CI
on:
push:
branches: [main]
# No base-branch filter. A `branches:` allowlist here gates on the PR's *base*,
# so a PR stacked onto another PR's branch ran no CI at all (see #1440, which
# targets #1381's branch and has zero CI runs). Every PR gets the gate; the
# push trigger above stays scoped to main.
pull_request:
branches: [main]

permissions:
contents: read
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ name: "CodeQL Analysis"
on:
push:
branches: [ "main" ]
# No base-branch filter — see the note in ci.yml. CodeQL is a security gate;
# a stacked PR must not be able to skip it by targeting a non-main base.
pull_request:
branches: [ "main" ]
schedule:
- cron: '0 6 * * 1'

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ on:
paths-ignore:
- "**/*.md"
- "**/*.txt"
# No base-branch filter — see the note in ci.yml.
pull_request:
branches: [main, develop]
paths-ignore:
- "**/*.md"
- "**/*.txt"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
name: 🔍 Dependency Review

on:
# No base-branch filter — see the note in ci.yml.
pull_request:
branches: [ main, develop ]
types: [opened, synchronize, reopened, ready_for_review]

permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ name: E2E Tests
on:
push:
branches: [main]
# No base-branch filter — see the note in ci.yml.
pull_request:
branches: [main]

permissions:
contents: read
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ name: Security Scan
on:
push:
branches: [main]
# No base-branch filter — see the note in ci.yml. A stacked PR must not be
# able to skip the security scan by targeting a branch other than main.
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday

Expand Down
77 changes: 77 additions & 0 deletions tests/unit/test_workflow_pr_gate_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Every PR quality gate must run on every PR, whatever its base branch.

A `pull_request.branches:` allowlist filters on the PR's *base*, not its head.
Six gates carried `branches: [main]`, so a PR stacked onto another PR's branch
matched none of them and ran with no CI, no CodeQL, no security scan, no
coverage, no e2e and no dependency review. #1440 -- a security fix targeting
#1381's branch -- reached "ready for review" with zero CI runs that way.

These tests pin the trigger shape so the allowlist cannot come back.
"""

from __future__ import annotations

from pathlib import Path

import pytest
import yaml

WORKFLOWS = Path(__file__).resolve().parents[2] / ".github/workflows"

# PR-triggered quality gates. Each must fire regardless of the PR's base branch.
GATED_WORKFLOWS = [
"ci.yml",
"security.yml",
"coverage.yml",
"e2e-tests.yml",
"codeql-analysis.yml",
"dependency-review.yml",
]


def _triggers(filename: str) -> dict:
path = WORKFLOWS / filename
assert path.exists(), f"{filename} should exist"
workflow = yaml.safe_load(path.read_text())
# PyYAML parses the YAML 'on' key as the Python bool True.
return workflow[True]


@pytest.mark.parametrize("filename", GATED_WORKFLOWS)
def test_gate_triggers_on_pull_request(filename: str) -> None:
assert "pull_request" in _triggers(filename), (
f"{filename} must run on pull_request to gate PRs at all"
)


@pytest.mark.parametrize("filename", GATED_WORKFLOWS)
def test_gate_has_no_base_branch_allowlist(filename: str) -> None:
"""The defect itself: a base-branch allowlist lets stacked PRs skip the gate."""
config = _triggers(filename)["pull_request"]
# A bare `pull_request:` parses to None, which is the shape we want.
if config is None:
return
assert "branches" not in config, (
f"{filename} filters pull_request on base branch {config['branches']!r}; "
"a PR stacked onto a non-main branch would skip this gate entirely"
)
assert "branches-ignore" not in config, (
f"{filename} uses branches-ignore on pull_request, which has the same "
"stacked-PR bypass as a branches allowlist"
)


@pytest.mark.parametrize("filename", GATED_WORKFLOWS)
def test_push_trigger_stays_scoped_to_main(filename: str) -> None:
"""Widening the PR trigger must not widen the push trigger with it.

Un-scoping `push` would run the full suite twice on every branch push.
"""
triggers = _triggers(filename)
if "push" not in triggers:
return
branches = triggers["push"].get("branches")
assert branches, f"{filename} push trigger should stay pinned to a branch list"
assert set(branches) <= {"main", "develop"}, (
f"{filename} push trigger widened to {branches!r}"
)
Loading