fix(ci): isolate ineligible PR walkthrough triggers (#3862)
This commit is contained in:
@@ -346,13 +346,70 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
|
||||
|
||||
def test_walkthrough_runs_are_serialized_for_the_entire_pr_pipeline(self) -> None:
|
||||
workflow_header = self.workflow.split("jobs:", 1)[0]
|
||||
normalized_header = " ".join(workflow_header.split())
|
||||
self.assertIn("concurrency:", workflow_header)
|
||||
self.assertIn("group: pr-walkthrough-${{ github.event.pull_request.number }}", workflow_header)
|
||||
self.assertIn(
|
||||
"group: >- pr-walkthrough-${{ github.event.pull_request.number }}${{",
|
||||
normalized_header,
|
||||
)
|
||||
self.assertIn("cancel-in-progress: true", workflow_header)
|
||||
self.assertNotIn("concurrency:", self.generation)
|
||||
self.assertIn("concurrency:", self.same_repo_review)
|
||||
self.assertIn("concurrency:", self.fork_review)
|
||||
|
||||
# @covers AC-CI-PR-WALK-003.5, AC-CI-PR-FAIL-005.1
|
||||
def test_walkthrough_concurrency_isolates_ineligible_label_events(self) -> None:
|
||||
workflow_header = " ".join(self.workflow.split("jobs:", 1)[0].split())
|
||||
for value in (
|
||||
"group: >- pr-walkthrough-${{ github.event.pull_request.number }}${{",
|
||||
"github.event.action == 'labeled'",
|
||||
"github.event.pull_request.head.repo.full_name == github.repository",
|
||||
"github.event.pull_request.head.repo.full_name != github.repository",
|
||||
"github.event.label.name == 'generate-pr-walkthrough'",
|
||||
"github.event.label.name == 'safe-to-review'",
|
||||
"&& '-ineligible' || ''",
|
||||
):
|
||||
self.assertIn(value, workflow_header)
|
||||
self.assertIn("cancel-in-progress: true", workflow_header)
|
||||
|
||||
# @covers AC-CI-PR-WALK-003.5, AC-CI-PR-FAIL-005.1
|
||||
def test_walkthrough_concurrency_matches_the_full_generation_gate(self) -> None:
|
||||
workflow_header = " ".join(self.workflow.split("jobs:", 1)[0].split())
|
||||
for value in (
|
||||
"vars.PR_WALKTHROUGH_ENABLED != 'true'",
|
||||
"github.event_name != 'pull_request_target'",
|
||||
"github.event.pull_request.draft != false",
|
||||
"github.event.action == 'opened'",
|
||||
"github.event.action == 'reopened'",
|
||||
"github.event.action == 'ready_for_review'",
|
||||
"github.event.action == 'synchronize'",
|
||||
"github.event.action == 'labeled' && github.event.label.name == 'generate-pr-walkthrough'",
|
||||
"github.event.action == 'labeled' && github.event.label.name == 'safe-to-review'",
|
||||
"github.event.action != 'labeled'",
|
||||
"contains(github.event.pull_request.labels.*.name, 'safe-to-review')",
|
||||
"vars.CLAUDE_REVIEW_ALLOWLIST != ''",
|
||||
"contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)",
|
||||
):
|
||||
self.assertIn(value, workflow_header)
|
||||
self.assertIn(
|
||||
"github.event.pull_request.head.repo.full_name == github.repository && "
|
||||
"(github.event.action == 'opened' || "
|
||||
"github.event.action == 'reopened' || "
|
||||
"github.event.action == 'ready_for_review' || "
|
||||
"github.event.action == 'synchronize' || "
|
||||
"(github.event.action == 'labeled' && "
|
||||
"github.event.label.name == 'generate-pr-walkthrough'))",
|
||||
workflow_header,
|
||||
)
|
||||
self.assertIn(
|
||||
"github.event.pull_request.head.repo.full_name != github.repository && "
|
||||
"( (github.event.action == 'labeled' && "
|
||||
"github.event.label.name == 'safe-to-review') || "
|
||||
"(github.event.action != 'labeled' && (",
|
||||
workflow_header,
|
||||
)
|
||||
self.assertIn("!(", workflow_header)
|
||||
|
||||
def test_walkthrough_label_does_not_run_the_normal_code_review(self) -> None:
|
||||
self.assertIn("github.event.action != 'labeled'", self.same_repo_review)
|
||||
self.assertIn(
|
||||
|
||||
@@ -16,7 +16,39 @@ env:
|
||||
PR_WALKTHROUGH_VARIANT: high
|
||||
|
||||
concurrency:
|
||||
group: pr-walkthrough-${{ github.event.pull_request.number }}
|
||||
# GitHub resolves workflow concurrency before it evaluates the job `if`.
|
||||
# Keep every event rejected by the generation gate out of the canceling
|
||||
# group so it cannot terminate an eligible run for the same pull request.
|
||||
group: >-
|
||||
pr-walkthrough-${{ github.event.pull_request.number }}${{
|
||||
(
|
||||
vars.PR_WALKTHROUGH_ENABLED != 'true' ||
|
||||
github.event_name != 'pull_request_target' ||
|
||||
github.event.pull_request.draft != false ||
|
||||
!(
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(github.event.action == 'opened' ||
|
||||
github.event.action == 'reopened' ||
|
||||
github.event.action == 'ready_for_review' ||
|
||||
github.event.action == 'synchronize' ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'generate-pr-walkthrough'))
|
||||
) ||
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name != github.repository &&
|
||||
(
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'safe-to-review') ||
|
||||
(github.event.action != 'labeled' &&
|
||||
(
|
||||
contains(github.event.pull_request.labels.*.name, 'safe-to-review') ||
|
||||
(vars.CLAUDE_REVIEW_ALLOWLIST != '' &&
|
||||
contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login))
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
) && '-ineligible' || ''
|
||||
}}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR-2026-09-22-pr-walkthrough-eligibility-concurrency: Isolate ineligible PR walkthrough triggers
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-09-22
|
||||
**Area:** workflow
|
||||
|
||||
## Context
|
||||
|
||||
The PR walkthrough workflow receives every supported pull request event, but
|
||||
its generation decision runs in the job's `if` expression. GitHub applies the
|
||||
workflow concurrency group before that job condition. Any event that fails the
|
||||
generation gate can therefore cancel an eligible run for the same pull
|
||||
request, including an unauthorized fork update, a draft event, or an event
|
||||
received while generation is disabled.
|
||||
|
||||
PR #3708 demonstrated this failure. The `safe-to-review` event started an
|
||||
eligible generation run. A simultaneous `safe-to-test` event entered the same
|
||||
per-pull-request group, canceled the eligible run, and then skipped its own
|
||||
generation job because `safe-to-test` is not an authorization label.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep one canceling concurrency group for eligible walkthrough triggers, and
|
||||
route every event rejected by the full generation gate to a separate
|
||||
per-pull-request group before the job-level authorization gate runs.
|
||||
|
||||
The workflow-level expression mirrors the generation gate's toggle, event,
|
||||
draft, repository, action, label, and allowlist conditions. Eligible
|
||||
same-repository `generate-pr-walkthrough` events and contributor
|
||||
`safe-to-review` or allowlisted events remain in
|
||||
`pr-walkthrough-<number>`. Every other event uses an `-ineligible` suffix. The
|
||||
job-level authorization expression remains the source of permission and
|
||||
continues to fail closed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A newer eligible trigger still cancels an older eligible pipeline for the
|
||||
same pull request.
|
||||
- An event rejected by the generation gate can no longer cancel generation,
|
||||
publication, or linking for an eligible trigger.
|
||||
- Rejected events still create skipped workflow runs, which preserve the
|
||||
existing audit trail and do not grant any capability.
|
||||
- The concurrency expression must remain aligned with the job-level trigger
|
||||
eligibility rules, and the workflow contract test must protect both paths.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Set `cancel-in-progress: false` for every walkthrough run. Rejected because
|
||||
it changes the existing latest-eligible-run behavior and can spend model
|
||||
capacity on stale eligible heads.
|
||||
- Remove the `labeled` event trigger. Rejected because approved contributor
|
||||
pull requests would lose the required `safe-to-review` activation path.
|
||||
- Give every label its own concurrency group. Rejected because eligible
|
||||
labels would no longer serialize with synchronize and other eligible events
|
||||
for the same pull request.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
created: 2026-09-22
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-CI-PR-WALK-003
|
||||
- REQ-CI-PR-TRUST-001
|
||||
- REQ-CI-PR-FAIL-005
|
||||
system_design:
|
||||
- ../../specs/ci/system-design/unified-contributor-pr-automation.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: PR walkthrough eligibility concurrency
|
||||
|
||||
## Overview
|
||||
|
||||
Update the PR walkthrough workflow so an event rejected by the generation gate
|
||||
cannot cancel an eligible contributor walkthrough run. Keep latest-run
|
||||
cancellation for eligible triggers, preserve the existing authorization gate,
|
||||
and add workflow contract regression coverage.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Separate every event rejected by the full generation gate from the eligible
|
||||
per-pull-request concurrency group.
|
||||
- Preserve `safe-to-review` as the only contributor approval label.
|
||||
- Add contract tests for the reported `safe-to-review` and `safe-to-test` race
|
||||
and an unauthorized fork non-labeled event.
|
||||
- Document the manual rerun procedure for an approved contributor PR.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Changing walkthrough generation, rendering, publication, or R2 hosting.
|
||||
- Adding a new GitHub Actions trigger or changing the model configuration.
|
||||
- Removing labels or changing PR #3708 from this repository.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Change `.github/workflows/pr-walkthrough.yml` so its concurrency expression
|
||||
mirrors the complete generation job gate, including the feature toggle, event
|
||||
type, draft state, repository, action, label, and allowlist conditions. Eligible
|
||||
same-repository `generate-pr-walkthrough` events and authorized contributor
|
||||
events use the existing shared group. Every rejected event uses an
|
||||
`-ineligible` suffix. Keep `cancel-in-progress: true` and the job-level
|
||||
authorization expression unchanged.
|
||||
|
||||
Extend `.github/scripts/pr-walkthrough-workflow-contract_test.py` to require the
|
||||
ineligible group branch and the complete source, action, label, and allowlist
|
||||
conditions. The test continues to require latest-run cancellation and the
|
||||
absence of `safe-to-test` from authorization expressions.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| --- | --- |
|
||||
| AC-CI-PR-WALK-003.1 | Existing generation gate contract plus the eligible-group assertions. |
|
||||
| AC-CI-PR-WALK-003.2 | Existing generation and publication contract tests. |
|
||||
| AC-CI-PR-WALK-003.5 | New concurrency contract tests covering eligible labels, ineligible labels, and unauthorized fork non-labeled events. |
|
||||
| AC-CI-PR-TRUST-001.2 | Existing assertion that `safe-to-test` is absent from authorization. |
|
||||
| AC-CI-PR-FAIL-005.1 | Existing trusted job-gate contract plus the new group isolation assertion. |
|
||||
|
||||
## Operational rerun
|
||||
|
||||
For an approved contributor PR, a new head commit triggers `synchronize` while
|
||||
the `safe-to-review` label remains present. A label-only rerun can remove any
|
||||
stale `safe-to-test` label, remove `safe-to-review`, and add `safe-to-review`
|
||||
again. The `safe-to-test` removal is not a trigger; the final `safe-to-review`
|
||||
addition is the trigger. Same-repository PRs can use the existing
|
||||
`generate-pr-walkthrough` label.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Guard ineligible walkthrough triggers](task-01-guard-ineligible-triggers.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
- `python3 .github/scripts/pr-walkthrough-workflow-contract_test.py` passed.
|
||||
- `python3 .github/scripts/lint-action-pinning_test.py` passed.
|
||||
- `python3 scripts/list-docs.py validate` passed.
|
||||
- `python3 scripts/lint-spec-files.py --all` passed.
|
||||
- `git diff --check` passed.
|
||||
- `zizmor .github/workflows` ran but remains non-zero for existing findings,
|
||||
including the pre-existing `pull_request_target` warning in the changed
|
||||
walkthrough workflow; the new concurrency expression introduced no finding.
|
||||
- `go run github.com/rhysd/actionlint/cmd/actionlint@latest` with
|
||||
`.github/workflows/pr-walkthrough.yml` passed.
|
||||
|
||||
## Risks
|
||||
|
||||
- The workflow-level expression must remain semantically aligned with the
|
||||
complete job-level generation gate, including its toggle and draft checks.
|
||||
- GitHub evaluates concurrency before the job condition, so the contract test
|
||||
must protect the group split as well as the authorization expression.
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
---
|
||||
id: "01-guard-ineligible-triggers"
|
||||
title: "Guard ineligible walkthrough triggers"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-CI-PR-WALK-003
|
||||
- REQ-CI-PR-TRUST-001
|
||||
- REQ-CI-PR-FAIL-005
|
||||
acceptance_criteria:
|
||||
- AC-CI-PR-WALK-003.1
|
||||
- AC-CI-PR-WALK-003.5
|
||||
- AC-CI-PR-TRUST-001.2
|
||||
- AC-CI-PR-FAIL-005.1
|
||||
system_design:
|
||||
- ../../specs/ci/system-design/unified-contributor-pr-automation.md
|
||||
---
|
||||
|
||||
# Task 01: Guard ineligible walkthrough triggers
|
||||
|
||||
## Summary
|
||||
|
||||
Adjust the PR walkthrough workflow concurrency expression so an event rejected
|
||||
by the full generation gate cannot cancel an eligible run for the same pull
|
||||
request. Add contract coverage for label and non-labeled rejected events and
|
||||
preserve the existing job-level authorization behavior.
|
||||
|
||||
## In scope
|
||||
|
||||
- Update the top-level concurrency group in
|
||||
`.github/workflows/pr-walkthrough.yml`.
|
||||
- Add assertions to
|
||||
`.github/scripts/pr-walkthrough-workflow-contract_test.py`.
|
||||
- Verify the existing `safe-to-review`, allowlist, draft, toggle, and
|
||||
same-repository rerun gates remain unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Walkthrough content, renderer behavior, R2 publication, or PR-body linking.
|
||||
- A new manual-dispatch workflow input.
|
||||
- Changes to preview or code-review workflows.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Contributor `safe-to-review` events and same-repository
|
||||
`generate-pr-walkthrough` events remain in the canceling per-PR group.
|
||||
- An ineligible label event such as `safe-to-test` and an unauthorized fork
|
||||
non-labeled event use a separate group and cannot cancel an eligible
|
||||
walkthrough pipeline.
|
||||
- The contract test passes and still rejects `safe-to-test` as an authorization
|
||||
source.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
python3 .github/scripts/pr-walkthrough-workflow-contract_test.py
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `.github/workflows/pr-walkthrough.yml`
|
||||
- `.github/scripts/pr-walkthrough-workflow-contract_test.py`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- A mismatch between workflow-level group selection and the job-level gate
|
||||
could either waste a run or allow an unintended cancellation path.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-CI-PR-WALK-003.1` and `REQ-CI-PR-WALK-003.5`.
|
||||
- `REQ-CI-PR-TRUST-001.2` and `REQ-CI-PR-FAIL-005.1`.
|
||||
- [Unified contributor pull request automation system design](../../specs/ci/system-design/unified-contributor-pr-automation.md).
|
||||
- [Eligibility concurrency ADR](../../decisions/2026-09-22-pr-walkthrough-eligibility-concurrency.md).
|
||||
|
||||
## Results
|
||||
|
||||
- Updated the workflow-level concurrency expression to mirror the complete
|
||||
generation gate. Every rejected event uses a per-pull-request
|
||||
`-ineligible` group while eligible triggers retain the canceling shared
|
||||
group.
|
||||
- Added contract coverage for source-specific labels, unauthorized fork
|
||||
non-labeled events, the global gate conditions, the ineligible suffix, and
|
||||
latest-run cancellation.
|
||||
- `python3 .github/scripts/pr-walkthrough-workflow-contract_test.py` passed
|
||||
with 29 tests.
|
||||
- `python3 .github/scripts/lint-action-pinning_test.py` and
|
||||
`git diff --check` passed.
|
||||
- `go run github.com/rhysd/actionlint/cmd/actionlint@latest` with
|
||||
`.github/workflows/pr-walkthrough.yml` passed.
|
||||
@@ -90,6 +90,12 @@ changes, including the changes that previously skipped walkthrough generation.
|
||||
- **AC-CI-PR-WALK-003.4:** The workflow shall verify that fetched contributor
|
||||
data matches the exact event head SHA before preparing context or publishing
|
||||
a result.
|
||||
- **AC-CI-PR-WALK-003.5:** When GitHub delivers multiple supported events for
|
||||
one pull request, a run whose event does not satisfy the walkthrough
|
||||
generation gate, including an unauthorized fork event, a draft event, or an
|
||||
event received while walkthrough generation is disabled, shall not cancel or
|
||||
supersede an eligible walkthrough run for the same pull request. Eligible
|
||||
walkthrough runs shall retain latest-run cancellation behavior.
|
||||
|
||||
### REQ-CI-PR-REVIEW-004: Existing review and preview capabilities
|
||||
|
||||
|
||||
@@ -79,6 +79,29 @@ workflow run.
|
||||
- The existing OpenCode and preview event sets remain unchanged except for the
|
||||
approval label expression.
|
||||
|
||||
### Eligibility-aware concurrency
|
||||
|
||||
GitHub applies workflow-level concurrency before it evaluates a job-level
|
||||
`if`. The walkthrough workflow therefore mirrors the full generation gate when
|
||||
it selects the per-pull-request concurrency group. The shared
|
||||
`pr-walkthrough-<number>` group is used only when walkthrough generation is
|
||||
enabled, the event is non-draft and `pull_request_target`, and the source,
|
||||
action, label, and contributor allowlist conditions authorize generation.
|
||||
Events that fail any of those conditions use an `-ineligible` suffix.
|
||||
|
||||
This includes unauthorized fork opened, ready-for-review, reopened, and
|
||||
synchronize events, as well as ineligible label events, draft events, and
|
||||
events received while generation is disabled. Same-repository
|
||||
`generate-pr-walkthrough` events and contributor `safe-to-review` events remain
|
||||
in the shared group. The existing job-level authorization gate remains the
|
||||
source of permission; group selection does not authorize a job.
|
||||
|
||||
The eligible group keeps `cancel-in-progress: true`, so a newer eligible
|
||||
walkthrough trigger still replaces an older run for the same pull request.
|
||||
An ineligible event can be skipped independently and cannot cancel an eligible
|
||||
generation, publication, or linking pipeline. This preserves the
|
||||
`safe-to-review` authorization boundary.
|
||||
|
||||
### Walkthrough artifact contract
|
||||
|
||||
The walkthrough keeps the existing provider-neutral contract:
|
||||
@@ -159,6 +182,9 @@ The public object key remains
|
||||
- Missing, malformed, or non-matching allowlists fail closed.
|
||||
- A missing `safe-to-review` label skips contributor jobs. A stale
|
||||
`safe-to-test` label has no authorization effect after migration.
|
||||
- An event that fails the walkthrough generation gate cannot cancel an eligible
|
||||
walkthrough pipeline; it is skipped independently after its separate
|
||||
concurrency group is selected.
|
||||
- A failed label write does not grant access and does not block direct
|
||||
allowlist paths.
|
||||
- A missing or mismatched pull request ref fails walkthrough context
|
||||
@@ -235,3 +261,4 @@ trigger.
|
||||
- [Use a Filesystem Contract for PR Walkthrough Runners](../../../decisions/2026-08-22-pr-walkthrough-filesystem-runner.md)
|
||||
- [Use the workflow SHA for trusted PR walkthrough inputs](../../../decisions/2026-08-23-pr-walkthrough-workflow-provenance.md)
|
||||
- [Host PR walkthrough HTML in Cloudflare R2](../../../decisions/2026-08-22-pr-walkthrough-r2-hosting.md)
|
||||
- [Isolate ineligible PR walkthrough triggers](../../../decisions/2026-09-22-pr-walkthrough-eligibility-concurrency.md)
|
||||
|
||||
Reference in New Issue
Block a user