Skip to content

chore(sec): pr-labels.yml + qa-gate.yml → pull_request_target#409

Merged
cmeans merged 1 commit into
mainfrom
chore/self-gating-workflows-pull-request-target
Apr 28, 2026
Merged

chore(sec): pr-labels.yml + qa-gate.yml → pull_request_target#409
cmeans merged 1 commit into
mainfrom
chore/self-gating-workflows-pull-request-target

Conversation

@cmeans-claude-dev
Copy link
Copy Markdown
Contributor

@cmeans-claude-dev cmeans-claude-dev Bot commented Apr 28, 2026

Summary

Migrates the two remaining self-gating workflows (pr-labels.yml, qa-gate.yml) from pull_request to pull_request_target so the gating logic a PR is evaluated under is always the base-branch (main) copy — closing the same self-bypass hole that drove the v0.18.4 CLA-bypass migration (#387 retrospective).

With pull_request, a same-repo PR runs the workflow file from the PR branch with a write-capable GITHUB_TOKEN. A contributor with push access could:

  • teach pr-labels.yml to apply QA Approved automatically inside their own PR, or
  • teach qa-gate.yml to post QA Gate=success regardless of label state,

and have the modified version run on that same PR. pull_request_target always evaluates the workflow file from the base branch, so a PR cannot alter the gate that applies to it.

qa-gate.yml additionally hardened: the inline '${{ toJSON(github.event.pull_request.labels.*.name) }}' shell grep replaced with a PR_LABELS env var, and ${{ github.repository }} / ${{ github.event.pull_request.head.sha }} interpolated into the gh api URL line lifted into REPO / HEAD_SHA env vars — same cascade-consistency pattern already in pr-labels.yml / pr-labels-ci.yml.

Why this is safe under pull_request_target

The classic pull_request_target hazard is checking out and executing untrusted PR code with elevated permissions. Neither workflow checks out the PR branch or executes PR code — both only read PR metadata from github.event and write labels/statuses via the GitHub API. The base-branch checkout (which pull_request_target defaults to) is never invoked.

Other workflows audited, intentionally left on pull_request

Workflow Trigger Why
ci.yml pull_request Executes PR code (ruff/mypy/pytest). Switching to pull_request_target with secrets access would be unsafe.
gitleaks.yml pull_request Scans PR head ref.
semgrep.yml pull_request Scans PR head ref.
pip-audit.yml pull_request Audits PR's lockfile.
docker-smoke.yml pull_request Builds the PR's Dockerfile.
cla-bot-bypass.yml pull_request_target Already migrated in v0.18.4 (#387).
dependabot-changelog.yml pull_request_target Already on the safe trigger.
pr-labels-ci.yml workflow_run Always runs from default branch by design.

Bootstrap caveat — read this before reviewing

This introduction PR will not auto-label or auto-status itself:

  • The base branch (main) still has on: pull_request: for both workflows; the PR branch has on: pull_request_target:.
  • pull_request events look at the PR-branch workflow file → trigger doesn't match → workflow doesn't fire.
  • pull_request_target events look at the base-branch workflow file → trigger doesn't match → workflow doesn't fire.

So on this PR specifically:

  • pr-labels.yml won't add Awaiting CI on push or transition labels.
  • qa-gate.yml won't post the QA Gate status check.

pr-labels-ci.yml (workflow_run) is unaffected and will still promote Awaiting CIReady for QA once CI completes — but only if Awaiting CI is added first. Manual label progression on this PR; future PRs pick up the new behavior automatically once this lands.

This is the same one-time bootstrap that the v0.18.4 CLA-bypass migration required, and it's correct: introducing a self-gating mechanism should require a manual review + merge, otherwise the mechanism bootstraps itself, which is the same circularity that makes the hole exploitable.

Test plan

CI-only change. No code or test count changes.

QA

Prerequisites

  • None — pure workflow YAML change.

Manual tests

    • Workflow YAML parses cleanly under both triggers. GitHub will reject syntactically invalid YAML on push; CI workflow runs themselves serve as the primary lint. Spot-check by viewing the Actions tab on this PR — confirm GitHub did not flag the workflow file with a "Workflow file" error annotation. Expected: no parse errors annotated on either changed workflow.
    • pr-labels.yml and qa-gate.yml did not fire on this PR. This is the documented bootstrap behavior. Expected: in the Actions tab for this PR, neither workflow has a run associated with this PR's events (only pr-labels-ci.yml runs may appear, fired by workflow_run from ci.yml).
    • pr-labels-ci.yml still promotes correctly via workflow_run. Once CI passes on this PR and Awaiting CI is manually added, expected: pr-labels-ci.yml removes Awaiting CI and adds Ready for QA. (This validates the workflow_run path is unaffected by this change.)
    • Post-merge spot check on the next PR. After this merges, the next PR opened against main should:
    • get Awaiting CI added automatically by pr-labels.yml on opening (now firing under pull_request_target from main)
    • get a QA Gate commit status posted by qa-gate.yml (now firing under pull_request_target from main)

    This step happens on a future PR, not this one — note it as a verification to perform when reviewing the next merged PR.

Manual interventions required for this PR

Because the gating workflows don't fire on this PR, QA must take the following steps manually instead of waiting for automation:

  • Add Awaiting CI label after pushing (so pr-labels-ci.yml can promote on CI green).
  • After QA testing, add QA Approved label as usual; then post the QA Gate status manually:
    gh api repos/cmeans/mcp-awareness/statuses/<HEAD_SHA> --method POST -f state=success -f description="QA testing completed (manual post — qa-gate.yml does not fire on its own introduction PR)" -f context="QA Gate"
    

Closes the same self-bypass hole that drove the v0.18.4 CLA-bypass
migration (#387 retrospective): with `pull_request`, a same-repo PR runs
the workflow file from the PR branch with a write-capable GITHUB_TOKEN,
letting a contributor with push access edit the label-discipline rules
or QA Gate logic inside their own PR. `pull_request_target` always
evaluates the base-branch copy of the workflow.

Both workflows are safe under `pull_request_target` because neither
checks out the PR branch or executes PR code — they only read PR
metadata from `github.event` and write labels/statuses via the GitHub
API.

`qa-gate.yml` additionally hardened: inline
`'${{ toJSON(github.event.pull_request.labels.*.name) }}'` shell grep
replaced with a `PR_LABELS` env var, and `${{ github.repository }}` /
`${{ github.event.pull_request.head.sha }}` interpolated into the
`gh api` URL line lifted into `REPO` / `HEAD_SHA` env vars — same
cascade-consistency hardening already in pr-labels.yml /
pr-labels-ci.yml.

Caveat: introduction PR will not auto-label or auto-status itself —
base branch still has `pull_request`, PR has `pull_request_target`,
neither matches the other event. Manual label progression and a
manual `QA Gate` status set required this once. Future PRs pick up
the new behavior after merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cmeans-claude-dev cmeans-claude-dev Bot requested a review from cmeans as a code owner April 28, 2026 19:13
@cmeans-claude-dev cmeans-claude-dev Bot added Dev Active Developer is actively working on this PR; QA should not start Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels Apr 28, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cmeans-claude-dev cmeans-claude-dev Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA Dev Active Developer is actively working on this PR; QA should not start labels Apr 28, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label Apr 28, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[QA] Round 1 — Ready for QA Signoff. Zero findings.

CI-only change closing the same self-bypass class as #387. 3 files, +42/-4. All 17 status checks green; merge state BLOCKED only because qa-gate.yml doesn't fire on its own introduction PR (documented bootstrap caveat — QA Gate status will be posted manually by maintainer at signoff time).

Code review

Area Verdict
Trigger swap (pull_requestpull_request_target) on pr-labels.yml + qa-gate.yml Correct. Neither workflow checks out the PR branch or executes PR code; both only read github.event and write via the GitHub API, so the classic pull_request_target hazard does not apply.
qa-gate.yml env-var hardening (PR_LABELS / REPO / HEAD_SHA) Matches the cascade pattern already in pr-labels.yml / pr-labels-ci.yml. Contributor-controlled values now flow through env, never inlined into the shell command line.
Repo-wide trigger audit Verified by grep: PR-body table matches .github/workflows/*.yml exactly. The five execute-PR-code workflows (ci, gitleaks, semgrep, pip-audit, docker-smoke) correctly stay on pull_request. cla-bot-bypass.yml and dependabot-changelog.yml already on pull_request_target. pr-labels-ci.yml on workflow_run. No other gating workflow needs migration.
CHANGELOG entry placement ## [Unreleased]### Security after ### Fixed. KaC v1.1.0 ordering correct. Wording is rich and accurate (covers both the trigger swap and the env-var hardening).

Manual tests (PR-body checkboxes 1–3 verified, 4 deferred)

  1. Workflow YAML parses cleanly. All CI workflows on the PR head succeeded; no parse-error annotations on either changed workflow file. ✓
  2. pr-labels.yml and qa-gate.yml did not fire on this PR. Verified via repos/cmeans/mcp-awareness/actions/runs?head_sha=b68df74...: only cla-bot-bypass (success) and Dependabot CHANGELOG (skipped) ran on pull_request_target; the five pull_request runs are ci/gitleaks/semgrep/pip-audit. No pr-labels.yml or qa-gate.yml runs exist on this SHA. ✓
  3. pr-labels-ci.yml still fires via workflow_run. Run 25072746466 (2026-04-28 19:16:40 UTC) found PR #409 via the head_branch fallback, observed Dev Active on the PR, and exited cleanly with "Dev Active present — skipping promotion". The workflow_run path is unaffected. ✓
  4. Post-merge spot check on the next PR. Deferred to next PR's QA review — please verify there that pr-labels.yml auto-applies Awaiting CI on opening, and qa-gate.yml posts the QA Gate status under the new trigger.

Live-bootstrap evidence on this PR

Current PR labels: Ready for QA, QA Active. Under the base-branch (still pull_request) pr-labels.yml, adding QA Active would trigger the on-label cleanup and remove Ready for QA. The fact that Ready for QA lingered after I added QA Active is direct visible confirmation that pr-labels.yml is not firing on this PR — exactly the bootstrap behavior the PR body documents.

Manual interventions for this PR

Per the PR body, after maintainer applies QA Approved, post the QA Gate status manually:

gh api repos/cmeans/mcp-awareness/statuses/b68df7423085d134d159536652a3e71055600bb0 --method POST -f state=success -f description="QA testing completed (manual post — qa-gate.yml does not fire on its own introduction PR)" -f context="QA Gate"

Applying Ready for QA Signoff as the final act.

Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[QA] Round 1 — Ready for QA Signoff. Zero findings.

CI-only change closing the same self-bypass class as #387. 3 files, +42/-4. All 17 status checks green; merge state BLOCKED only because qa-gate.yml doesn't fire on its own introduction PR (documented bootstrap caveat — QA Gate status will be posted manually by maintainer at signoff time).

Code review

Area Verdict
Trigger swap (pull_requestpull_request_target) on pr-labels.yml + qa-gate.yml Correct. Neither workflow checks out the PR branch or executes PR code; both only read github.event and write via the GitHub API, so the classic pull_request_target hazard does not apply.
qa-gate.yml env-var hardening (PR_LABELS / REPO / HEAD_SHA) Matches the cascade pattern already in pr-labels.yml / pr-labels-ci.yml. Contributor-controlled values now flow through env, never inlined into the shell command line.
Repo-wide trigger audit Verified by grep: PR-body table matches .github/workflows/*.yml exactly. The five execute-PR-code workflows (ci, gitleaks, semgrep, pip-audit, docker-smoke) correctly stay on pull_request. cla-bot-bypass.yml and dependabot-changelog.yml already on pull_request_target. pr-labels-ci.yml on workflow_run. No other gating workflow needs migration.
CHANGELOG entry placement ## [Unreleased]### Security after ### Fixed. KaC v1.1.0 ordering correct. Wording is rich and accurate (covers both the trigger swap and the env-var hardening).

Manual tests (PR-body checkboxes 1–3 verified, 4 deferred)

  1. Workflow YAML parses cleanly. All CI workflows on the PR head succeeded; no parse-error annotations on either changed workflow file. ✓
  2. pr-labels.yml and qa-gate.yml did not fire on this PR. Verified via repos/cmeans/mcp-awareness/actions/runs?head_sha=b68df74...: only cla-bot-bypass (success) and Dependabot CHANGELOG (skipped) ran on pull_request_target; the five pull_request runs are ci/gitleaks/semgrep/pip-audit. No pr-labels.yml or qa-gate.yml runs exist on this SHA. ✓
  3. pr-labels-ci.yml still fires via workflow_run. Run 25072746466 (2026-04-28 19:16:40 UTC) found PR #409 via the head_branch fallback, observed Dev Active on the PR, and exited cleanly with "Dev Active present — skipping promotion". The workflow_run path is unaffected. ✓
  4. Post-merge spot check on the next PR. Deferred to next PR's QA review — please verify there that pr-labels.yml auto-applies Awaiting CI on opening, and qa-gate.yml posts the QA Gate status under the new trigger.

Live-bootstrap evidence on this PR

Current PR labels: Ready for QA, QA Active. Under the base-branch (still pull_request) pr-labels.yml, adding QA Active would trigger the on-label cleanup and remove Ready for QA. The fact that Ready for QA lingered after I added QA Active is direct visible confirmation that pr-labels.yml is not firing on this PR — exactly the bootstrap behavior the PR body documents.

Manual interventions for this PR

Per the PR body, after maintainer applies QA Approved, post the QA Gate status manually:

gh api repos/cmeans/mcp-awareness/statuses/b68df7423085d134d159536652a3e71055600bb0 --method POST -f state=success -f description="QA testing completed (manual post — qa-gate.yml does not fire on its own introduction PR)" -f context="QA Gate"

Applying Ready for QA Signoff as the final act.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented Apr 28, 2026

[QA] Applying Ready for QA Signoff — all 17 checks pass, zero findings on Round 1. CI-only workflow YAML change with verified bootstrap behavior (neither pr-labels.yml nor qa-gate.yml fired on this PR's events; pr-labels-ci.yml fired correctly via workflow_run and skipped due to Dev Active). PR-body checkboxes 1–3 verified, 4 deferred to next PR's QA. Note: there is a duplicate of my QA review comment (gh CLI silent-success during retry); GitHub does not allow deleting COMMENTED reviews — content is identical.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge QA Approved Manual QA testing completed and passed and removed QA Active QA is actively reviewing; Dev should not push changes Ready for QA Dev work complete — QA can begin review Ready for QA Signoff QA passed — ready for maintainer final review and merge labels Apr 28, 2026
@cmeans cmeans merged commit 7179702 into main Apr 28, 2026
18 checks passed
@cmeans cmeans deleted the chore/self-gating-workflows-pull-request-target branch April 28, 2026 19:44
cmeans-claude-dev Bot added a commit that referenced this pull request Apr 28, 2026
…#378) (#410)

## Summary

Closes #378. Two stale-label traps in `pr-labels-ci.yml` fixed
symmetrically; both rooted in narrow outer guards that only fired on
`Awaiting CI`, missing the post-`CI Failed` recovery arc and the `Ready
for QA → CI Failed` regression arc.

| Job | Today | Now |
| --- | --- | --- |
| `on-ci-pass` | Promotes only when `Awaiting CI` is present | Promotes
when `Awaiting CI` OR `CI Failed` is present |
| `on-ci-fail` | Adds `CI Failed` only when `Awaiting CI` is present |
Adds `CI Failed` when `Awaiting CI` OR `Ready for QA` is present |

### Bug 1 — `CI Failed → CI pass` silently no-ops (issue #378)

Reproduction trail in #377 (2026-04-22): a lint-failing push moved
labels to `CI Failed`; the fix-up push made CI go green; `on-ci-pass`
fired and ran, but its outer `if echo "$LABELS" | grep -q "^Awaiting
CI$"` was false (only `CI Failed` was present), so it silently no-op'd.
PR sat at `CI Failed` while CI was actually green. Required a manual `gh
pr edit --remove-label "CI Failed" --add-label "Ready for QA"` to
unstick.

### Bug 2 — `Ready for QA → CI re-fail` keeps the green label
(symmetric)

Mirror trap on `on-ci-fail`: a CI re-run on a PR sitting at `Ready for
QA` (e.g., manual re-trigger after a flake, or a workflow change forcing
a re-run) that turns red leaves the PR labelled `Ready for QA` because
the outer `if echo "$LABELS" | grep -q "^Awaiting CI$"` is false. The
status check goes red but the label still says ready — QA might pick it
up assuming CI is green.

### Review-state preservation

Broadening the triggers introduces a new risk: if a `QA Active` / `Ready
for QA Signoff` / `QA Approved` label coexists with a CI label (race, or
manual mistake), the broader trigger could overwrite review-machine
state with `Ready for QA` (on pass) or `CI Failed` (on fail). To prevent
that, both jobs now short-circuit explicitly when any of those three
labels is present:

```bash
for QA_STATE in "QA Active" "Ready for QA Signoff" "QA Approved"; do
  if echo "$LABELS" | grep -q "^$QA_STATE$"; then
    echo "$QA_STATE present — skipping (review in progress)"
    exit 0
  fi
done
```

Rationale: review state advances independently of CI re-runs. A passing
or failing CI re-run on a PR that's already in QA review is visible via
the check itself; the label transition would be redundant on success and
destructive on failure. `Dev Active` short-circuit preserved unchanged.

### Safety

- Trigger remains `workflow_run` — base-branch context, immune to
PR-branch edit attacks (same protection class as the
`pull_request_target` migration in #409).
- No new contributor-controlled inputs. Label list still read via `gh pr
view --json labels` (repo-owned strings, not fork-controlled).
- All grep patterns remain anchored (`^Label$`) so labels like `Awaiting
CI Failed` (if one ever existed) cannot accidentally satisfy a
`^Awaiting CI$` check.
- Existing env-routing of `HEAD_BRANCH` / `RUN_ID` / `PR` / `REPO`
(hardened in #332/#333) is unchanged. Nothing I add interpolates new
contributor-controlled values into shell.

### State-machine trace (full)

Pre-state → CI conclusion → resulting transition (✓ = covered, ✗ =
no-op, * = new):

| Pre-state | CI = success | CI = failure |
| --- | --- | --- |
| `Awaiting CI` | → `Ready for QA` ✓ | → `CI Failed` ✓ |
| `CI Failed` | → `Ready for QA` ✓* | stays `CI Failed` ✓ |
| `Ready for QA` | stays `Ready for QA` ✓ | → `CI Failed` ✓* |
| `Dev Active` | no-op (skip) ✓ | no-op (skip) ✓ |
| `QA Active` | no-op (skip) ✓* | no-op (skip) ✓* |
| `Ready for QA Signoff` | no-op (skip) ✓* | no-op (skip) ✓* |
| `QA Approved` | no-op (skip) ✓* | no-op (skip) ✓* |

The * entries are new in this PR. The `Dev Active` and "no pre-state"
cases were already correct.

## Test plan

Workflow YAML only. No tests to add.

## QA

### Prerequisites
- None — pure workflow YAML change.

### Manual tests

1. - [x] **Workflow YAML parses cleanly.** Confirm the Actions tab on
this PR shows no parse-error annotations on `pr-labels-ci.yml`.

2. - [x] **Diff matches the state-machine trace table above.** Read
`.github/workflows/pr-labels-ci.yml` head-to-toe; for each row of the
trace, confirm the corresponding code path emits the expected transition
(or skip).

3. - [x] **#409 migration live-validation (deferred from #409 QA test
plan #4).** This is the first PR opened against `main` since the
`pr-labels.yml` / `qa-gate.yml` migration to `pull_request_target`.
Confirm:
- `pr-labels.yml` `on-push` fired on opening: `Awaiting CI` was applied
automatically (no manual addition required this time).
- `qa-gate.yml` posted a `QA Gate` status on this PR's head SHA from app
`15368` (GitHub Actions). Visible in the status-check rollup.
- These two observations together confirm #409's migration works
end-to-end on a real PR — not just on the introduction PR's
bootstrap-skipped path.

4. - [ ] **Verification of the bug-fix itself is post-merge.**
`workflow_run` triggers always run from the default branch (per the
`LIMITATION` comment at the top of `pr-labels-ci.yml`), so this PR's
changes do not run on this PR. The natural validation is the next
CI-fail-then-pass PR after this lands — when that happens, the PR should
auto-promote `CI Failed → Ready for QA` without manual intervention.
Reviewer should add a follow-up note here (or in the awareness milestone
for this PR) once that natural validation occurs.

### Out-of-scope follow-ups (not for this PR)

- The `dismiss_stale_reviews_on_push` setting interacts with these
transitions in subtle ways (review approvals get auto-dismissed on push,
then CI re-runs). No change proposed; just flagging for awareness.
- A future enhancement could add a `QA Invalidated` style label for the
case where CI re-fails on a PR in QA review, but doing so requires
designing the QA recovery path. Out of scope for #378.

Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant