Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 36 additions & 23 deletions .github/workflows/pr-metadata.yml
Original file line number Diff line number Diff line change
@@ -1,63 +1,76 @@
---
name: PR metadata validation

on:
workflow_run:
workflows: ["PR Sync"]
types: [completed]
"on":
pull_request_target:
types:
- opened
- synchronize
- reopened
- edited

permissions:
contents: read
issues: write
pull-requests: read
pull-requests: write

concurrency:
group: pr-metadata-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }}
group: pr-metadata-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true

jobs:
validate-pr:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request_target' &&
github.event.workflow_run.pull_requests[0].number != null
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Checkout trusted default branch
- name: Checkout trusted base commit
uses: actions/checkout@v6
with:
ref: ${{ github.event.repository.default_branch }}
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"

- name: Validate branch name and pull request metadata
- name: Autofill recoverable pull request metadata
env:
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
python -m project_setup.pr_autofill \
--repo "$GITHUB_REPOSITORY" \
--event-path "$GITHUB_EVENT_PATH"

- name: Validate live branch name and pull request metadata
env:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
python scripts/validation/validate_pr_body.py \
--repo "$REPOSITORY" \
--pr-number "$PR_NUMBER" \
--skip-draft-or-closed \
--comment

qa-live:
name: live-qa-after-guardrails
needs: validate-pr
if: >-
needs.validate-pr.result == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
github.event.workflow_run.pull_requests[0].head.ref == 'Q.A' &&
github.event.workflow_run.pull_requests[0].base.ref == 'main'
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.head.ref == 'Q.A' &&
github.event.pull_request.base.ref == 'main'
uses: ./.github/workflows/qa-live.yml
with:
checkout_ref: refs/heads/Q.A
pr_number: ${{ github.event.workflow_run.pull_requests[0].number }}
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit

qa-deployment-cleanup:
Expand All @@ -66,18 +79,18 @@ jobs:
if: >-
always() &&
needs.qa-live.result != 'skipped' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
github.event.workflow_run.pull_requests[0].head.ref == 'Q.A' &&
github.event.workflow_run.pull_requests[0].base.ref == 'main'
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.head.ref == 'Q.A' &&
github.event.pull_request.base.ref == 'main'
permissions:
contents: read
deployments: write
runs-on: ubuntu-latest
steps:
- name: Checkout trusted default branch
- name: Checkout trusted base commit
uses: actions/checkout@v6
with:
ref: ${{ github.event.repository.default_branch }}
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false

- name: Set up Python
Expand Down
43 changes: 24 additions & 19 deletions .github/workflows/pr-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,57 @@ name: PR Sync
"on":
pull_request_target:
types:
- opened
- synchronize
- reopened
- edited
- ready_for_review
- converted_to_draft
- closed
workflow_run:
workflows: ["PR metadata validation"]
types: [completed]

permissions:
contents: read
issues: write
pull-requests: write

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.run_id }}
cancel-in-progress: true

jobs:
sync:
if: >-
github.event.pull_request.head.repo.full_name == github.repository
(
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository
) || (
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request_target' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
github.event.workflow_run.pull_requests[0].number != null
)
runs-on: ubuntu-latest
steps:
- name: Checkout trusted base commit
- name: Checkout trusted base commit for lifecycle events
if: github.event_name == 'pull_request_target'
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false

- name: Checkout trusted base branch after guardrails
if: github.event_name == 'workflow_run'
uses: actions/checkout@v6
with:
ref: refs/heads/${{ github.event.workflow_run.pull_requests[0].base.ref }}
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"

- name: Autofill recoverable pull request metadata
env:
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
python -m project_setup.pr_autofill \
--repo "$GITHUB_REPOSITORY" \
--event-path "$GITHUB_EVENT_PATH"

- name: Synchronize pull request context
- name: Synchronize live pull request context
env:
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
Expand Down
159 changes: 159 additions & 0 deletions docs/repo/pr-governance-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# PR Governance Architecture

## Purpose

This document is the execution contract for pull request governance in GitHub Project Automation (GPA).

The reference behavior comes from the proven Take Your Pills governance lane. The important invariant is the ordering of state-changing and state-consuming stages:

> **Autofill -> Guardrails -> PR Sync**

PR Sync is the generic GPA successor to the reference repository's PR Hygiene stage. Autofill is not part of PR Sync execution; it is a preparation step inside Guardrails.

## Architecture

```mermaid
flowchart TD
A[PR opened / synchronize / reopened / edited] --> G[PR metadata validation<br/>Guardrails]

G --> AF[Autofill recoverable metadata<br/>Linked Issue + Milestone]
AF --> LIVE1[Read live PR state from GitHub]
LIVE1 --> V{Branch and body valid?}

V -- No --> STOP[Stop governance lane<br/>write/update validation feedback]
V -- Yes --> WR[workflow_run: Guardrails succeeded]

WR --> S[PR Sync]
S --> LIVE2[Read live PR state from GitHub]
LIVE2 --> TASK[Resolve canonical linked issue/task]
TASK --> META[Sync labels / milestone / assignees]
META --> REL[Sync parent / sub-issue relationship]
REL --> PROJ[Sync optional Project v2 status]
PROJ --> DONE[Prepared and synchronized PR]

L[ready_for_review<br/>converted_to_draft<br/>closed] --> S

V -- Yes and Q.A -> main --> QA[Live Q.A sandbox]
QA --> QAC[Clean sandbox resources and historical Q.A deployments]
```

## Why the order matters

`pull_request_target` payloads are snapshots. If Autofill changes a PR body and a synchronization stage immediately consumes the original event payload, that stage can observe stale metadata.

GPA therefore does not use the original PR-event body as the handoff from Autofill to PR Sync.

The safe handoff is:

1. Autofill mutates the real pull request through the GitHub API.
2. Guardrails validates the **live pull request** from GitHub.
3. A successful Guardrails run emits a separate `workflow_run` event.
4. PR Sync resolves the associated PR number and fetches the **live pull request** again before synchronization.

This is the same architectural fix used by the Take Your Pills reference after stale PR state was observed in independent governance workflows.

## Workflow responsibilities

### `.github/workflows/pr-metadata.yml` — Guardrails role

Triggers directly from trusted `pull_request_target` events:

- `opened`;
- `synchronize`;
- `reopened`;
- `edited`.

Execution order inside the workflow:

1. Checkout the trusted base commit.
2. Run `project_setup.pr_autofill`.
3. Run `scripts/validation/validate_pr_body.py`.
4. The validator reads the live PR through the GitHub API when repository and PR number are available.
5. For a valid `Q.A -> main` promotion, run the live Q.A sandbox and its cleanup lane.

Guardrails owns validation. It does not synchronize task-derived PR metadata.

### `.github/workflows/pr-sync.yml` — Sync/Hygiene role

Normal implementation synchronization is triggered only by:

```text
workflow_run(PR metadata validation = success)
```

Direct `pull_request_target` handling is restricted to lifecycle events that need a state transition without another implementation validation pass:

- `ready_for_review`;
- `converted_to_draft`;
- `closed`.

For a `workflow_run`, `project_setup.pr_sync` reconstructs the context by fetching the associated pull request from GitHub. It must not use a stale body inherited from the original PR webhook.

PR Sync owns:

- linked implementation issue/task resolution;
- configured label synchronization;
- milestone synchronization;
- assignee synchronization;
- parent/sub-issue synchronization;
- optional Project v2 membership/status synchronization;
- the marked PR Sync status comment.

## Promotion pull requests

Implementation-task mutation is skipped for configured promotion paths. The GPA defaults are:

```text
develop -> Q.A
Q.A -> main
```

These PRs can still pass Guardrails and can participate in promotion-specific validation such as live Q.A, but PR Sync must not invent or require an implementation task for them.

## Authentication boundary

Repository-scoped PR/issue operations use the built-in Actions token:

```text
github.token
```

The relevant workflows request:

```yaml
permissions:
contents: read
issues: write
pull-requests: write
```

This token is used for PR body updates, comments, labels, milestones, assignees, and repository-scoped issue relationships.

`PROJECT_SETUP_PAT` is an optional, separate boundary for GitHub Projects v2 operations. Missing Project v2 credentials must not prevent repository-scoped PR synchronization.

## Security invariants

- Privileged automation executes trusted base/default-branch code.
- PR head code is never executed with write credentials by the governance lane.
- Fork PRs are skipped by privileged mutations.
- `persist-credentials` is disabled on trusted checkouts.
- Guardrails must succeed before normal PR Sync runs.
- Normal PR Sync must fetch live PR state after Guardrails.
- Promotion PR exclusions are evaluated before implementation-task mutation.
- Project v2 credentials remain isolated from ordinary repository mutations.

## Regression contract

A newly opened implementation PR with a deterministically resolvable branch may start with placeholder Linked Issue/Milestone fields. Without manual editing, rerunning, or adding a second commit, the automation must converge to:

```text
Autofill live PR
-> validate live PR
-> successful workflow_run
-> refetch live PR
-> PR Sync
```

If Guardrails fails, normal PR Sync must not run.

The workflow contract tests in `tests/test_pr_sync.py` and `tests/test_pr_sync_autofill.py` protect this ordering and the live-state refetch behavior.
Loading