From d18b07894824c4bcbf73b070c9c94e215e69ace7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:20:34 +0900 Subject: [PATCH 01/11] ci(actions): scope superseded PR cancellation Signed-off-by: Seongho Bae --- .github/workflows/code-quality.yml | 4 ++++ .github/workflows/r.yml | 4 ++-- .github/workflows/security-audit.yml | 4 ++++ .../ci/test_workflow_concurrency_contract.py | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 scripts/ci/test_workflow_concurrency_contract.py diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index e58bb331..bc4d75c1 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: quality: runs-on: ubuntu-latest diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 54eef61a..ec7ae9d1 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -11,8 +11,8 @@ 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.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: check: diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index b6e46e2a..d149ec9a 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: secret-and-workflow-audit: runs-on: ubuntu-latest diff --git a/scripts/ci/test_workflow_concurrency_contract.py b/scripts/ci/test_workflow_concurrency_contract.py new file mode 100644 index 00000000..98fd5d4c --- /dev/null +++ b/scripts/ci/test_workflow_concurrency_contract.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +WORKFLOWS = Path(".github/workflows") +EXPECTED_GROUP = "${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}" +EXPECTED_CANCEL = "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" + + +def main() -> None: + files = sorted(WORKFLOWS.glob("*.yml")) + assert files, "no workflows found" + for path in files: + text = path.read_text() + assert f"group: {EXPECTED_GROUP}" in text, f"{path}: unsafe concurrency group" + assert EXPECTED_CANCEL in text, f"{path}: unsafe cancellation policy" + + +if __name__ == "__main__": + main() From defb0cad64d203e6658cc20e06bf8d39463edd00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:22:28 +0900 Subject: [PATCH 02/11] fix(actions): keep concurrency expressions lintable Preserve per-workflow, repository, and PR scoping while using the event payload's absent-value fallback to keep workflow YAML within the repository line limit.\n\nCo-Authored-By: OpenAI Codex Signed-off-by: Seongho Bae --- .github/workflows/code-quality.yml | 2 +- .github/workflows/r.yml | 2 +- .github/workflows/security-audit.yml | 2 +- scripts/ci/test_workflow_concurrency_contract.py | 5 ++++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index bc4d75c1..f5eba170 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -10,7 +10,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index ec7ae9d1..6e060912 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -11,7 +11,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index d149ec9a..79178f6c 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -10,7 +10,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: diff --git a/scripts/ci/test_workflow_concurrency_contract.py b/scripts/ci/test_workflow_concurrency_contract.py index 98fd5d4c..e9c45ae5 100644 --- a/scripts/ci/test_workflow_concurrency_contract.py +++ b/scripts/ci/test_workflow_concurrency_contract.py @@ -2,7 +2,10 @@ WORKFLOWS = Path(".github/workflows") -EXPECTED_GROUP = "${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}" +EXPECTED_GROUP = ( + "${{ github.workflow }}-${{ github.repository }}-" + "${{ github.event.pull_request.number || github.run_id }}" +) EXPECTED_CANCEL = "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" From 40922f69279acd8a3e4a05b8940b7dacae0343e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:40:40 +0900 Subject: [PATCH 03/11] test(actions): expose structural concurrency-contract gaps --- ...test_workflow_concurrency_contract_unit.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 scripts/ci/test_workflow_concurrency_contract_unit.py diff --git a/scripts/ci/test_workflow_concurrency_contract_unit.py b/scripts/ci/test_workflow_concurrency_contract_unit.py new file mode 100644 index 00000000..9628ea9b --- /dev/null +++ b/scripts/ci/test_workflow_concurrency_contract_unit.py @@ -0,0 +1,65 @@ +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 +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + check: + 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_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_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) + + +if __name__ == "__main__": + unittest.main() From 8aef37b04998a99eb5a68c8c8cb8d22a74361437 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:41:01 +0900 Subject: [PATCH 04/11] fix(actions): validate structural concurrency contract --- .../ci/test_workflow_concurrency_contract.py | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/scripts/ci/test_workflow_concurrency_contract.py b/scripts/ci/test_workflow_concurrency_contract.py index e9c45ae5..3d370515 100644 --- a/scripts/ci/test_workflow_concurrency_contract.py +++ b/scripts/ci/test_workflow_concurrency_contract.py @@ -1,3 +1,5 @@ +"""Validate repository-owned workflow concurrency without parsing lookalike text.""" + from pathlib import Path @@ -6,16 +8,59 @@ "${{ github.workflow }}-${{ github.repository }}-" "${{ github.event.pull_request.number || github.run_id }}" ) -EXPECTED_CANCEL = "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" +EXPECTED_CANCEL = "${{ github.event_name == 'pull_request' }}" + + +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.strip() + for line in lines[start:end] + if line.strip() and not line.lstrip().startswith("#") + ] + + +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" def main() -> None: - files = sorted(WORKFLOWS.glob("*.yml")) + """Validate every source-backed workflow in the repository.""" + files = discover_workflows() assert files, "no workflows found" for path in files: - text = path.read_text() - assert f"group: {EXPECTED_GROUP}" in text, f"{path}: unsafe concurrency group" - assert EXPECTED_CANCEL in text, f"{path}: unsafe cancellation policy" + validate_workflow_text(path, path.read_text()) if __name__ == "__main__": From 76fb157e77cdc6234d1fc22488aedd50c3995446 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:41:15 +0900 Subject: [PATCH 05/11] ci(actions): execute concurrency contract regressions --- .github/workflows/code-quality.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index f5eba170..a67ad613 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -31,6 +31,11 @@ jobs: python3 -m pip install --user yamllint python3 -m yamllint .yamllint.yml .github/dependabot.yml .github/workflows/*.yml + - name: Validate workflow concurrency contract + run: | + python3 scripts/ci/test_workflow_concurrency_contract_unit.py + python3 scripts/ci/test_workflow_concurrency_contract.py + - name: Lint markdown docs run: | npm install -g markdownlint-cli2@0.18.1 From 4638c77e9254f56d5385c95e291e8c3085992af1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:08:12 +0900 Subject: [PATCH 06/11] ci: skip draft pull request jobs Signed-off-by: Seongho Bae --- .github/workflows/code-quality.yml | 2 ++ .github/workflows/r.yml | 2 ++ .github/workflows/security-audit.yml | 2 ++ scripts/ci/test_workflow_concurrency_contract.py | 8 ++++++++ .../ci/test_workflow_concurrency_contract_unit.py | 13 ++++++++++++- 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index a67ad613..4ebf4714 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -4,6 +4,7 @@ on: push: branches: ["master", "main"] pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: ["master", "main"] permissions: @@ -15,6 +16,7 @@ concurrency: jobs: quality: + if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 6e060912..36ccb95c 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -4,6 +4,7 @@ on: push: branches: ["master", "main"] pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: ["master", "main"] workflow_dispatch: @@ -16,6 +17,7 @@ concurrency: jobs: check: + if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} runs-on: ubuntu-latest env: R_PROFILE_USER: /dev/null diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 79178f6c..2db781e4 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -4,6 +4,7 @@ on: push: branches: ["master", "main"] pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: ["master", "main"] permissions: @@ -15,6 +16,7 @@ concurrency: jobs: secret-and-workflow-audit: + if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} runs-on: ubuntu-latest steps: diff --git a/scripts/ci/test_workflow_concurrency_contract.py b/scripts/ci/test_workflow_concurrency_contract.py index 3d370515..959911ac 100644 --- a/scripts/ci/test_workflow_concurrency_contract.py +++ b/scripts/ci/test_workflow_concurrency_contract.py @@ -9,6 +9,9 @@ "${{ 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, converted_to_draft, closed]" +) def discover_workflows(root: Path = WORKFLOWS) -> list[Path]: @@ -53,6 +56,11 @@ def validate_workflow_text(path: Path, text: str) -> None: assert cancellations == [ f"cancel-in-progress: {EXPECTED_CANCEL}" ], f"{path}: unsafe cancellation policy" + if "\n pull_request:\n" in text: + assert EXPECTED_PR_TYPES in text, f"{path}: incomplete pull-request lifecycle" + assert ( + "github.event.pull_request.draft == false" in text + ), f"{path}: draft pull requests occupy a runner" def main() -> None: diff --git a/scripts/ci/test_workflow_concurrency_contract_unit.py b/scripts/ci/test_workflow_concurrency_contract_unit.py index 9628ea9b..d406ca15 100644 --- a/scripts/ci/test_workflow_concurrency_contract_unit.py +++ b/scripts/ci/test_workflow_concurrency_contract_unit.py @@ -6,12 +6,15 @@ VALID = """name: Example -on: pull_request +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] concurrency: group: ${{ github.workflow }}-${{ github.repository }}-${{ 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.action != 'closed' && github.event.pull_request.draft == false) }} runs-on: ubuntu-latest """ @@ -60,6 +63,14 @@ def test_rejects_unconditional_cancellation(self) -> None: 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) + if __name__ == "__main__": unittest.main() From 4b2ca03d19561fb18bb582d32ee66903c99e7a47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:22:31 +0900 Subject: [PATCH 07/11] test(actions): reject nested concurrency lookalikes Validate direct YAML children for concurrency, pull-request lifecycle, and job admission so comments or nested keys cannot satisfy the queue contract. Commit-Message-Assisted-by: OpenAI Codex Signed-off-by: Seongho Bae --- .../ci/test_workflow_concurrency_contract.py | 81 +++++++++++++++++-- ...test_workflow_concurrency_contract_unit.py | 27 ++++++- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/scripts/ci/test_workflow_concurrency_contract.py b/scripts/ci/test_workflow_concurrency_contract.py index 959911ac..06f0ae64 100644 --- a/scripts/ci/test_workflow_concurrency_contract.py +++ b/scripts/ci/test_workflow_concurrency_contract.py @@ -12,6 +12,10 @@ EXPECTED_PR_TYPES = ( "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]" ) +EXPECTED_PR_ADMISSION = ( + "${{ github.event_name != 'pull_request' || " + "(github.event.action != 'closed' && github.event.pull_request.draft == false) }}" +) def discover_workflows(root: Path = WORKFLOWS) -> list[Path]: @@ -40,12 +44,71 @@ def _top_level_concurrency_lines(path: Path, text: str) -> list[str]: break return [ - line.strip() + line[2:] for line in lines[start:end] - if line.strip() and not line.lstrip().startswith("#") + 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) @@ -56,11 +119,15 @@ def validate_workflow_text(path: Path, text: str) -> None: assert cancellations == [ f"cancel-in-progress: {EXPECTED_CANCEL}" ], f"{path}: unsafe cancellation policy" - if "\n pull_request:\n" in text: - assert EXPECTED_PR_TYPES in text, f"{path}: incomplete pull-request lifecycle" - assert ( - "github.event.pull_request.draft == false" in text - ), f"{path}: draft pull requests occupy a runner" + 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 or closed pull requests occupy a runner" def main() -> None: diff --git a/scripts/ci/test_workflow_concurrency_contract_unit.py b/scripts/ci/test_workflow_concurrency_contract_unit.py index d406ca15..e0f50b0b 100644 --- a/scripts/ci/test_workflow_concurrency_contract_unit.py +++ b/scripts/ci/test_workflow_concurrency_contract_unit.py @@ -46,6 +46,13 @@ def test_rejects_nested_lookalike(self) -> None: 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") @@ -68,9 +75,27 @@ def test_rejects_draft_runner_admission(self) -> None: "github.event.pull_request.draft == false", "github.event.pull_request.draft == true", ) - with self.assertRaisesRegex(AssertionError, "draft pull requests"): + with self.assertRaisesRegex(AssertionError, "draft or closed pull requests"): validate_workflow_text(Path("draft.yml"), malformed) + def test_rejects_missing_closed_runner_admission(self) -> None: + malformed = VALID.replace("github.event.action != 'closed' && ", "") + with self.assertRaisesRegex(AssertionError, "draft or closed pull requests"): + validate_workflow_text(Path("closed.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, converted_to_draft, closed]", + "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() From f51ce02ba7778f2190559dd0e085084edae04d5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:27:16 +0900 Subject: [PATCH 08/11] test(actions): reject no-op pull-request lifecycle runs --- ...test_workflow_concurrency_contract_unit.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/ci/test_workflow_concurrency_contract_unit.py b/scripts/ci/test_workflow_concurrency_contract_unit.py index e0f50b0b..130c7ac4 100644 --- a/scripts/ci/test_workflow_concurrency_contract_unit.py +++ b/scripts/ci/test_workflow_concurrency_contract_unit.py @@ -8,13 +8,13 @@ VALID = """name: Example on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] concurrency: group: ${{ github.workflow }}-${{ github.repository }}-${{ 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.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest """ @@ -33,6 +33,14 @@ def test_discovers_yml_and_yaml(self) -> None: 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 @@ -75,17 +83,12 @@ def test_rejects_draft_runner_admission(self) -> None: "github.event.pull_request.draft == false", "github.event.pull_request.draft == true", ) - with self.assertRaisesRegex(AssertionError, "draft or closed pull requests"): + with self.assertRaisesRegex(AssertionError, "draft pull requests"): validate_workflow_text(Path("draft.yml"), malformed) - def test_rejects_missing_closed_runner_admission(self) -> None: - malformed = VALID.replace("github.event.action != 'closed' && ", "") - with self.assertRaisesRegex(AssertionError, "draft or closed pull requests"): - validate_workflow_text(Path("closed.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, converted_to_draft, closed]", + "on:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review]", "on: [pull_request]", ) with self.assertRaisesRegex(AssertionError, "pull-request trigger"): From 986c84d0523b1b6683cd251b5937133489add649 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:33:48 +0900 Subject: [PATCH 09/11] ci(actions): stop no-op pull request lifecycle runs Signed-off-by: Seongho Bae --- .github/workflows/code-quality.yml | 4 ++-- .github/workflows/r.yml | 4 ++-- .github/workflows/security-audit.yml | 4 ++-- scripts/ci/test_workflow_concurrency_contract.py | 9 +++------ 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 4ebf4714..dca538ce 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -4,7 +4,7 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] branches: ["master", "main"] permissions: @@ -16,7 +16,7 @@ concurrency: jobs: quality: - if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 36ccb95c..2a25ef57 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -4,7 +4,7 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] branches: ["master", "main"] workflow_dispatch: @@ -17,7 +17,7 @@ concurrency: jobs: check: - if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest env: R_PROFILE_USER: /dev/null diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 2db781e4..3df703ac 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -4,7 +4,7 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] branches: ["master", "main"] permissions: @@ -16,7 +16,7 @@ concurrency: jobs: secret-and-workflow-audit: - if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: diff --git a/scripts/ci/test_workflow_concurrency_contract.py b/scripts/ci/test_workflow_concurrency_contract.py index 06f0ae64..d962ab7f 100644 --- a/scripts/ci/test_workflow_concurrency_contract.py +++ b/scripts/ci/test_workflow_concurrency_contract.py @@ -9,12 +9,9 @@ "${{ 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, converted_to_draft, closed]" -) +EXPECTED_PR_TYPES = "types: [opened, synchronize, reopened, ready_for_review]" EXPECTED_PR_ADMISSION = ( - "${{ github.event_name != 'pull_request' || " - "(github.event.action != 'closed' && github.event.pull_request.draft == false) }}" + "${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}" ) @@ -127,7 +124,7 @@ def validate_workflow_text(path: Path, text: str) -> None: ), 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 or closed pull requests occupy a runner" + ), f"{path}: draft pull requests occupy a runner" def main() -> None: From 9cfd133c0675df91a1f067b5a59b2b728e5a5df2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:35:55 +0900 Subject: [PATCH 10/11] fix(actions): retain lifecycle cancellation signals Draft and close events share the PR-stable workflow concurrency group, so they cancel obsolete work before runner admission while job guards prevent new work. Signed-off-by: Seongho Bae --- .github/workflows/code-quality.yml | 4 ++-- .github/workflows/r.yml | 4 ++-- .github/workflows/security-audit.yml | 4 ++-- .../ci/test_workflow_concurrency_contract.py | 9 +++++--- ...test_workflow_concurrency_contract_unit.py | 21 ++++++++----------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index dca538ce..4ebf4714 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -4,7 +4,7 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: ["master", "main"] permissions: @@ -16,7 +16,7 @@ concurrency: jobs: quality: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 2a25ef57..36ccb95c 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -4,7 +4,7 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: ["master", "main"] workflow_dispatch: @@ -17,7 +17,7 @@ concurrency: jobs: check: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} runs-on: ubuntu-latest env: R_PROFILE_USER: /dev/null diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 3df703ac..2db781e4 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -4,7 +4,7 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: ["master", "main"] permissions: @@ -16,7 +16,7 @@ concurrency: jobs: secret-and-workflow-audit: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} runs-on: ubuntu-latest steps: diff --git a/scripts/ci/test_workflow_concurrency_contract.py b/scripts/ci/test_workflow_concurrency_contract.py index d962ab7f..06f0ae64 100644 --- a/scripts/ci/test_workflow_concurrency_contract.py +++ b/scripts/ci/test_workflow_concurrency_contract.py @@ -9,9 +9,12 @@ "${{ 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_TYPES = ( + "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]" +) EXPECTED_PR_ADMISSION = ( - "${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}" + "${{ github.event_name != 'pull_request' || " + "(github.event.action != 'closed' && github.event.pull_request.draft == false) }}" ) @@ -124,7 +127,7 @@ def validate_workflow_text(path: Path, text: str) -> None: ), 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" + ), f"{path}: draft or closed pull requests occupy a runner" def main() -> None: diff --git a/scripts/ci/test_workflow_concurrency_contract_unit.py b/scripts/ci/test_workflow_concurrency_contract_unit.py index 130c7ac4..e0f50b0b 100644 --- a/scripts/ci/test_workflow_concurrency_contract_unit.py +++ b/scripts/ci/test_workflow_concurrency_contract_unit.py @@ -8,13 +8,13 @@ VALID = """name: Example on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] concurrency: group: ${{ github.workflow }}-${{ github.repository }}-${{ 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 }} + if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} runs-on: ubuntu-latest """ @@ -33,14 +33,6 @@ def test_discovers_yml_and_yaml(self) -> None: 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 @@ -83,12 +75,17 @@ def test_rejects_draft_runner_admission(self) -> None: "github.event.pull_request.draft == false", "github.event.pull_request.draft == true", ) - with self.assertRaisesRegex(AssertionError, "draft pull requests"): + with self.assertRaisesRegex(AssertionError, "draft or closed pull requests"): validate_workflow_text(Path("draft.yml"), malformed) + def test_rejects_missing_closed_runner_admission(self) -> None: + malformed = VALID.replace("github.event.action != 'closed' && ", "") + with self.assertRaisesRegex(AssertionError, "draft or closed pull requests"): + validate_workflow_text(Path("closed.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:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]", "on: [pull_request]", ) with self.assertRaisesRegex(AssertionError, "pull-request trigger"): From 893a07ffd2435b637be72dc3ff566a89053f9413 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:59:51 +0900 Subject: [PATCH 11/11] fix(actions): isolate reruns from current heads Only head-changing pull-request events enter the stable PR group; reruns fall back to their original run ID and cannot cancel newer evidence. Signed-off-by: Seongho Bae --- .github/workflows/code-quality.yml | 6 ++--- .github/workflows/r.yml | 6 ++--- .github/workflows/security-audit.yml | 6 ++--- .../ci/test_workflow_concurrency_contract.py | 12 ++++----- ...test_workflow_concurrency_contract_unit.py | 26 ++++++++++++------- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 4ebf4714..dbc73713 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -4,19 +4,19 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] branches: ["master", "main"] permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + 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: quality: - if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 36ccb95c..01dab867 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -4,7 +4,7 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] branches: ["master", "main"] workflow_dispatch: @@ -12,12 +12,12 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + 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.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest env: R_PROFILE_USER: /dev/null diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 2db781e4..3273528f 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -4,19 +4,19 @@ on: push: branches: ["master", "main"] pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] branches: ["master", "main"] permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + 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: secret-and-workflow-audit: - if: ${{ github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: diff --git a/scripts/ci/test_workflow_concurrency_contract.py b/scripts/ci/test_workflow_concurrency_contract.py index 06f0ae64..68b82e28 100644 --- a/scripts/ci/test_workflow_concurrency_contract.py +++ b/scripts/ci/test_workflow_concurrency_contract.py @@ -6,15 +6,13 @@ WORKFLOWS = Path(".github/workflows") EXPECTED_GROUP = ( "${{ github.workflow }}-${{ github.repository }}-" - "${{ github.event.pull_request.number || github.run_id }}" + "${{ 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, converted_to_draft, closed]" -) +EXPECTED_PR_TYPES = "types: [opened, synchronize, reopened, ready_for_review]" EXPECTED_PR_ADMISSION = ( - "${{ github.event_name != 'pull_request' || " - "(github.event.action != 'closed' && github.event.pull_request.draft == false) }}" + "${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}" ) @@ -127,7 +125,7 @@ def validate_workflow_text(path: Path, text: str) -> None: ), 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 or closed pull requests occupy a runner" + ), f"{path}: draft pull requests occupy a runner" def main() -> None: diff --git a/scripts/ci/test_workflow_concurrency_contract_unit.py b/scripts/ci/test_workflow_concurrency_contract_unit.py index e0f50b0b..4e58f609 100644 --- a/scripts/ci/test_workflow_concurrency_contract_unit.py +++ b/scripts/ci/test_workflow_concurrency_contract_unit.py @@ -8,13 +8,13 @@ VALID = """name: Example on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + types: [opened, synchronize, reopened, ready_for_review] concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + 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.action != 'closed' && github.event.pull_request.draft == false) }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest """ @@ -33,6 +33,14 @@ def test_discovers_yml_and_yaml(self) -> None: 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 @@ -75,17 +83,17 @@ def test_rejects_draft_runner_admission(self) -> None: "github.event.pull_request.draft == false", "github.event.pull_request.draft == true", ) - with self.assertRaisesRegex(AssertionError, "draft or closed pull requests"): + with self.assertRaisesRegex(AssertionError, "draft pull requests"): validate_workflow_text(Path("draft.yml"), malformed) - def test_rejects_missing_closed_runner_admission(self) -> None: - malformed = VALID.replace("github.event.action != 'closed' && ", "") - with self.assertRaisesRegex(AssertionError, "draft or closed pull requests"): - validate_workflow_text(Path("closed.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, converted_to_draft, closed]", + "on:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review]", "on: [pull_request]", ) with self.assertRaisesRegex(AssertionError, "pull-request trigger"):