-
Notifications
You must be signed in to change notification settings - Fork 0
ci(actions): scope superseded PR cancellation #329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d18b078
defb0ca
40922f6
8aef37b
76fb157
4638c77
4b2ca03
f51ce02
986c84d
9cfd133
893a07f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,18 +4,20 @@ | |
| push: | ||
| branches: ["master", "main"] | ||
| pull_request: | ||
| types: [opened, synchronize, reopened, ready_for_review] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win PR lifecycle cleanup 계약을 workflow와 테스트에서 함께 복원하세요.
As per coding guidelines, 동작 변경에는 먼저 테스트/fixture를 추가해야 합니다. 📍 Affects 4 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| branches: ["master", "main"] | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: r-cmd-check-${{ github.ref }} | ||
| cancel-in-progress: true | ||
| group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.number || github.run_id }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | ||
|
|
||
| jobs: | ||
| check: | ||
| if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} | ||
| runs-on: ubuntu-latest | ||
| env: | ||
| R_PROFILE_USER: /dev/null | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| """Validate repository-owned workflow concurrency without parsing lookalike text.""" | ||
|
|
||
| from pathlib import Path | ||
|
|
||
|
|
||
| WORKFLOWS = Path(".github/workflows") | ||
| EXPECTED_GROUP = ( | ||
| "${{ github.workflow }}-${{ github.repository }}-" | ||
| "${{ github.event_name == 'pull_request' && github.run_attempt == 1 && " | ||
| "github.event.pull_request.number || github.run_id }}" | ||
| ) | ||
| EXPECTED_CANCEL = "${{ github.event_name == 'pull_request' }}" | ||
| EXPECTED_PR_TYPES = "types: [opened, synchronize, reopened, ready_for_review]" | ||
| EXPECTED_PR_ADMISSION = ( | ||
| "${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}" | ||
| ) | ||
|
|
||
|
|
||
| def discover_workflows(root: Path = WORKFLOWS) -> list[Path]: | ||
| """Return every YAML workflow file under the repository workflow directory.""" | ||
| return sorted({*root.glob("*.yml"), *root.glob("*.yaml")}) | ||
|
|
||
|
|
||
| def _top_level_concurrency_lines(path: Path, text: str) -> list[str]: | ||
| """Return non-comment entries from the sole top-level concurrency block.""" | ||
| lines = text.splitlines() | ||
| starts = [ | ||
| index | ||
| for index, line in enumerate(lines) | ||
| if line == "concurrency:" | ||
| ] | ||
| assert len(starts) == 1, f"{path}: expected exactly one top-level concurrency block" | ||
|
|
||
| start = starts[0] + 1 | ||
| end = len(lines) | ||
| for index in range(start, len(lines)): | ||
| stripped = lines[index].strip() | ||
| if not stripped or stripped.startswith("#"): | ||
| continue | ||
| if lines[index][0] not in " \t": | ||
| end = index | ||
| break | ||
|
|
||
| return [ | ||
| line[2:] | ||
| for line in lines[start:end] | ||
| if line.startswith(" ") and not line.startswith(" ") | ||
| ] | ||
|
|
||
|
|
||
| def _has_pull_request_trigger(text: str) -> bool: | ||
| """Return whether the workflow has a top-level pull-request trigger block.""" | ||
| lines = text.splitlines() | ||
| try: | ||
| start = lines.index("on:") + 1 | ||
| except ValueError: | ||
| return False | ||
| for line in lines[start:]: | ||
| if line and not line[0].isspace(): | ||
| break | ||
| if line == " pull_request:": | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def _pull_request_types(text: str) -> list[str]: | ||
| """Return direct entries from the top-level pull-request trigger.""" | ||
| lines = text.splitlines() | ||
| start = lines.index(" pull_request:") + 1 | ||
| entries: list[str] = [] | ||
| for line in lines[start:]: | ||
| if line and ( | ||
| not line[0].isspace() | ||
| or (line.startswith(" ") and not line.startswith(" ")) | ||
| ): | ||
| break | ||
| if line.startswith(" ") and not line.startswith(" "): | ||
| entries.append(line[4:]) | ||
| return entries | ||
|
|
||
|
|
||
| def _job_admissions(text: str) -> list[str]: | ||
| """Return direct ``if`` values for every top-level job.""" | ||
| lines = text.splitlines() | ||
| start = lines.index("jobs:") + 1 | ||
| admissions: list[str] = [] | ||
| for index in range(start, len(lines)): | ||
| line = lines[index] | ||
| if line and not line[0].isspace(): | ||
| break | ||
| if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): | ||
| job_end = next( | ||
| ( | ||
| candidate | ||
| for candidate in range(index + 1, len(lines)) | ||
| if lines[candidate].startswith(" ") | ||
| and not lines[candidate].startswith(" ") | ||
| ), | ||
| len(lines), | ||
| ) | ||
| direct_if = [ | ||
| entry[8:] | ||
| for entry in lines[index + 1 : job_end] | ||
| if entry.startswith(" if: ") | ||
| ] | ||
| admissions.extend(direct_if or [""]) | ||
| return admissions | ||
|
|
||
|
|
||
| def validate_workflow_text(path: Path, text: str) -> None: | ||
| """Require exact group and PR-only cancellation values in top-level concurrency.""" | ||
| entries = _top_level_concurrency_lines(path, text) | ||
| groups = [entry for entry in entries if entry.startswith("group:")] | ||
| cancellations = [entry for entry in entries if entry.startswith("cancel-in-progress:")] | ||
|
|
||
| assert groups == [f"group: {EXPECTED_GROUP}"], f"{path}: unsafe concurrency group" | ||
| assert cancellations == [ | ||
| f"cancel-in-progress: {EXPECTED_CANCEL}" | ||
| ], f"{path}: unsafe cancellation policy" | ||
| assert _has_pull_request_trigger( | ||
| text | ||
| ), f"{path}: missing structured pull-request trigger" | ||
| assert EXPECTED_PR_TYPES in _pull_request_types( | ||
| text | ||
| ), f"{path}: incomplete pull-request lifecycle" | ||
| assert _job_admissions(text) and all( | ||
| admission == EXPECTED_PR_ADMISSION for admission in _job_admissions(text) | ||
| ), f"{path}: draft pull requests occupy a runner" | ||
|
|
||
|
|
||
| def main() -> None: | ||
| """Validate every source-backed workflow in the repository.""" | ||
| files = discover_workflows() | ||
| assert files, "no workflows found" | ||
| for path in files: | ||
| validate_workflow_text(path, path.read_text()) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import tempfile | ||
| import unittest | ||
| from pathlib import Path | ||
|
|
||
| from test_workflow_concurrency_contract import discover_workflows, validate_workflow_text | ||
|
|
||
|
|
||
| VALID = """name: Example | ||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize, reopened, ready_for_review] | ||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.number || github.run_id }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | ||
| jobs: | ||
| check: | ||
| if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} | ||
| runs-on: ubuntu-latest | ||
| """ | ||
|
|
||
|
|
||
| class WorkflowConcurrencyContractTest(unittest.TestCase): | ||
| """Exercise discovery and top-level concurrency parsing edge cases.""" | ||
|
|
||
| def test_discovers_yml_and_yaml(self) -> None: | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| root = Path(tmp) | ||
| (root / "a.yml").write_text("name: a\n") | ||
| (root / "b.yaml").write_text("name: b\n") | ||
| (root / "ignored.txt").write_text("name: ignored\n") | ||
| self.assertEqual([path.name for path in discover_workflows(root)], ["a.yml", "b.yaml"]) | ||
|
|
||
| def test_accepts_exact_top_level_contract(self) -> None: | ||
| validate_workflow_text(Path("valid.yml"), VALID) | ||
|
|
||
| def test_rejects_noop_pull_request_lifecycle_events(self) -> None: | ||
| malformed = VALID.replace( | ||
| "types: [opened, synchronize, reopened, ready_for_review]", | ||
| "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]", | ||
| ) | ||
| with self.assertRaisesRegex(AssertionError, "pull-request lifecycle"): | ||
| validate_workflow_text(Path("noop-events.yml"), malformed) | ||
|
|
||
| def test_rejects_nested_lookalike(self) -> None: | ||
| malformed = """name: Example | ||
| on: pull_request | ||
| jobs: | ||
| check: | ||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | ||
| runs-on: ubuntu-latest | ||
| """ | ||
| with self.assertRaisesRegex(AssertionError, "top-level concurrency"): | ||
| validate_workflow_text(Path("nested.yml"), malformed) | ||
|
|
||
| def test_rejects_nested_concurrency_entries(self) -> None: | ||
| malformed = VALID.replace(" group:", " policy:\n group:").replace( | ||
| " cancel-in-progress:", " cancel-in-progress:" | ||
| ) | ||
| with self.assertRaisesRegex(AssertionError, "unsafe concurrency group"): | ||
| validate_workflow_text(Path("nested-entries.yml"), malformed) | ||
|
|
||
| def test_rejects_duplicate_top_level_concurrency(self) -> None: | ||
| with self.assertRaisesRegex(AssertionError, "exactly one top-level concurrency"): | ||
| validate_workflow_text(Path("duplicate.yml"), VALID + "\nconcurrency:\n group: duplicate\n") | ||
|
|
||
| def test_rejects_wrong_group(self) -> None: | ||
| malformed = VALID.replace("github.repository", "github.ref") | ||
| with self.assertRaisesRegex(AssertionError, "unsafe concurrency group"): | ||
| validate_workflow_text(Path("wrong-group.yml"), malformed) | ||
|
|
||
| def test_rejects_unconditional_cancellation(self) -> None: | ||
| malformed = VALID.replace( | ||
| "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", | ||
| "cancel-in-progress: true", | ||
| ) | ||
| with self.assertRaisesRegex(AssertionError, "unsafe cancellation policy"): | ||
| validate_workflow_text(Path("wrong-cancel.yml"), malformed) | ||
|
|
||
| def test_rejects_draft_runner_admission(self) -> None: | ||
| malformed = VALID.replace( | ||
| "github.event.pull_request.draft == false", | ||
| "github.event.pull_request.draft == true", | ||
| ) | ||
| with self.assertRaisesRegex(AssertionError, "draft pull requests"): | ||
| validate_workflow_text(Path("draft.yml"), malformed) | ||
|
|
||
| def test_rejects_rerun_that_shares_the_pull_request_group(self) -> None: | ||
| malformed = VALID.replace(" && github.run_attempt == 1", "") | ||
| with self.assertRaisesRegex(AssertionError, "unsafe concurrency group"): | ||
| validate_workflow_text(Path("rerun.yml"), malformed) | ||
|
|
||
| def test_rejects_flow_style_pull_request_trigger(self) -> None: | ||
| malformed = VALID.replace( | ||
| "on:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review]", | ||
| "on: [pull_request]", | ||
| ) | ||
| with self.assertRaisesRegex(AssertionError, "pull-request trigger"): | ||
| validate_workflow_text(Path("flow.yml"), malformed) | ||
|
|
||
| def test_ignores_comment_lookalikes(self) -> None: | ||
| malformed = VALID.replace(" pull_request:", " # pull_request:") | ||
| with self.assertRaisesRegex(AssertionError, "pull-request trigger"): | ||
| validate_workflow_text(Path("comment.yml"), malformed) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🤖 get_repo_knowledge executed:
get_repo_knowledge ContextualWisdomLab/aFIPC /tmp/coderabbit-repo-knowledge/contextualwisdomlab-afipc-649c8ab4/conventionsLength of output: 8070
🏁 Script executed:
Repository: ContextualWisdomLab/aFIPC
Length of output: 4981
🏁 Script executed:
Repository: ContextualWisdomLab/aFIPC
Length of output: 2698
🏁 Script executed:
Repository: ContextualWisdomLab/aFIPC
Length of output: 3050
🏁 Script executed:
Repository: ContextualWisdomLab/aFIPC
Length of output: 10059
🌐 Web query:
site:docs.github.com GitHub Actions pull_request activity types concurrency cancel-in-progress workflow run💡 Result:
In GitHub Actions, you can manage the execution of concurrent workflows triggered by pull_request events using the concurrency key [1][2]. This feature ensures that only one workflow or job within a specified concurrency group runs at a time [1][3]. To cancel in-progress runs when a new event triggers the same workflow for the same concurrency group, use the cancel-in-progress: true option [1][2]. Key considerations for pull_request workflows include: 1. Concurrency Grouping: Because the github.head_ref context is only defined for pull_request events, you must provide a fallback value if your workflow also triggers on other events (e.g., push) to avoid syntax errors [1][4]. A common pattern for pull request workflows is: concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true 2. Triggering Activity Types: By default, a pull_request workflow runs only on opened, synchronize, or reopened activity types [5]. If you want to trigger your workflow on other activity types (e.g., labeled, review_requested), you must explicitly define them using the types keyword [5][2]: on: pull_request: types: [opened, synchronize, labeled] 3. Behavior: When concurrency is configured with cancel-in-progress: true, if a new workflow run is triggered for an existing concurrency group, any currently running or pending runs in that group will be canceled, allowing the new run to proceed [4][6]. This is particularly useful for workflows triggered by synchronize events, as it prevents running tests on outdated commits after a new push to the pull request [3]. Note that you cannot combine cancel-in-progress: true with queue: max, as these represent conflicting strategies for managing concurrent runs [4][6].
Citations:
🏁 Script executed:
Repository: ContextualWisdomLab/aFIPC
Length of output: 10059
PR 종료 및 초안 전환 이벤트를 trigger에 유지하고 계약 테스트를 갱신하세요.
현재
concurrency설정은 동일한 PR의 새 run이 생성될 때만 기존 run을 취소합니다.converted_to_draft와closed를 trigger에서 제외하면 해당 상태 변경에서 새 run이 생성되지 않으므로 실행 중인qualityjob이 계속 실행될 수 있습니다.이 이벤트를 추가하면
scripts/ci/test_workflow_concurrency_contract.py의EXPECTED_PR_TYPES와 draft admission 계약도 갱신해야 합니다.closed와converted_to_draft에서는qualityjob을 건너뛰도록 하고, 기존test_rejects_noop_pull_request_lifecycle_events테스트도 새 계약에 맞게 수정하세요.🤖 Prompt for AI Agents
Source: MCP tools