diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml
index 25512ca..f144b88 100644
--- a/.github/workflows/pr-metadata.yml
+++ b/.github/workflows/pr-metadata.yml
@@ -1,31 +1,33 @@
+---
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
@@ -33,17 +35,28 @@ jobs:
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:
@@ -51,13 +64,13 @@ jobs:
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:
@@ -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
diff --git a/.github/workflows/pr-sync.yml b/.github/workflows/pr-sync.yml
index 31848d3..cdc501c 100644
--- a/.github/workflows/pr-sync.yml
+++ b/.github/workflows/pr-sync.yml
@@ -4,13 +4,12 @@ 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
@@ -18,38 +17,44 @@ permissions:
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 }}
diff --git a/docs/repo/pr-governance-architecture.md b/docs/repo/pr-governance-architecture.md
new file mode 100644
index 0000000..e39bc39
--- /dev/null
+++ b/docs/repo/pr-governance-architecture.md
@@ -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
Guardrails]
+
+ G --> AF[Autofill recoverable metadata
Linked Issue + Milestone]
+ AF --> LIVE1[Read live PR state from GitHub]
+ LIVE1 --> V{Branch and body valid?}
+
+ V -- No --> STOP[Stop governance lane
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
converted_to_draft
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.
diff --git a/docs/repo/pr-governance-architecture.pt-BR.md b/docs/repo/pr-governance-architecture.pt-BR.md
new file mode 100644
index 0000000..cfee423
--- /dev/null
+++ b/docs/repo/pr-governance-architecture.pt-BR.md
@@ -0,0 +1,159 @@
+# Arquitetura de Governança de PR
+
+## Objetivo
+
+Este documento é o contrato de execução da governança de pull requests no GitHub Project Automation (GPA).
+
+O comportamento de referência vem do fluxo já comprovado no Take Your Pills. A regra principal é a ordem entre as etapas que alteram e consomem o estado do PR:
+
+> **Autofill -> Guardrails -> PR Sync**
+
+PR Sync é o sucessor genérico no GPA da etapa chamada PR Hygiene no repositório de referência. Autofill não faz parte da execução do PR Sync; ele prepara o PR dentro do Guardrails.
+
+## Arquitetura
+
+```mermaid
+flowchart TD
+ A[PR opened / synchronize / reopened / edited] --> G[PR metadata validation
Guardrails]
+
+ G --> AF[Autofill de metadata recuperável
Linked Issue + Milestone]
+ AF --> LIVE1[Ler estado vivo do PR no GitHub]
+ LIVE1 --> V{Branch e body válidos?}
+
+ V -- Não --> STOP[Interromper fluxo de governança
criar/atualizar feedback de validação]
+ V -- Sim --> WR[workflow_run: Guardrails concluído com sucesso]
+
+ WR --> S[PR Sync]
+ S --> LIVE2[Ler novamente o PR vivo no GitHub]
+ LIVE2 --> TASK[Resolver issue/task canônica vinculada]
+ TASK --> META[Sincronizar labels / milestone / assignees]
+ META --> REL[Sincronizar relação pai / sub-issue]
+ REL --> PROJ[Sincronizar status opcional no Project v2]
+ PROJ --> DONE[PR preparado e sincronizado]
+
+ L[ready_for_review
converted_to_draft
closed] --> S
+
+ V -- Sim e Q.A -> main --> QA[Sandbox Q.A live]
+ QA --> QAC[Limpar recursos do sandbox e deployments Q.A históricos]
+```
+
+## Por que a ordem importa
+
+Payloads de `pull_request_target` são snapshots. Se o Autofill altera o body de um PR e uma etapa de sincronização logo depois usa o payload original do evento, essa etapa pode consumir metadata antiga.
+
+Por isso o GPA não usa o body do evento original como mecanismo de passagem de estado entre Autofill e PR Sync.
+
+A passagem segura é:
+
+1. Autofill altera o pull request real pela API do GitHub.
+2. Guardrails valida o **PR vivo** obtido do GitHub.
+3. O sucesso do Guardrails gera um evento separado de `workflow_run`.
+4. PR Sync obtém o número do PR associado e busca novamente o **PR vivo** antes da sincronização.
+
+Essa é a mesma correção arquitetural adotada no Take Your Pills depois que o repositório de referência encontrou consumo de estado antigo do PR entre workflows de governança.
+
+## Responsabilidades dos workflows
+
+### `.github/workflows/pr-metadata.yml` — papel de Guardrails
+
+É acionado diretamente por eventos confiáveis de `pull_request_target`:
+
+- `opened`;
+- `synchronize`;
+- `reopened`;
+- `edited`.
+
+Ordem interna:
+
+1. Checkout do commit confiável da base.
+2. Executar `project_setup.pr_autofill`.
+3. Executar `scripts/validation/validate_pr_body.py`.
+4. O validator consulta o PR vivo pela API quando repository e número do PR estão disponíveis.
+5. Para uma promoção válida `Q.A -> main`, executar o sandbox live de Q.A e o fluxo de limpeza.
+
+Guardrails é responsável por validação. Ele não sincroniza metadata derivada da task para o PR.
+
+### `.github/workflows/pr-sync.yml` — papel de Sync/Hygiene
+
+A sincronização normal de implementação só é acionada por:
+
+```text
+workflow_run(PR metadata validation = success)
+```
+
+O tratamento direto por `pull_request_target` fica limitado aos eventos de ciclo de vida que precisam atualizar estado sem repetir a validação de implementação:
+
+- `ready_for_review`;
+- `converted_to_draft`;
+- `closed`.
+
+Quando recebe `workflow_run`, `project_setup.pr_sync` reconstrói o contexto buscando o pull request associado diretamente no GitHub. Ele não deve consumir um body antigo herdado do webhook original.
+
+PR Sync é responsável por:
+
+- resolver a issue/task de implementação vinculada;
+- sincronizar as famílias configuradas de labels;
+- sincronizar milestone;
+- sincronizar assignees;
+- sincronizar relação pai/sub-issue;
+- sincronizar opcionalmente membership/status do Project v2;
+- manter o comentário marcado de status do PR Sync.
+
+## PRs de promoção
+
+Mutações relacionadas à task de implementação são ignoradas nos caminhos de promoção configurados. Os padrões do GPA são:
+
+```text
+develop -> Q.A
+Q.A -> main
+```
+
+Esses PRs ainda podem passar pelo Guardrails e participar de validações específicas de promoção, como Q.A live, mas PR Sync não deve inventar nem exigir uma task de implementação para eles.
+
+## Fronteira de autenticação
+
+Operações de PR/issues restritas ao repositório usam o token nativo do Actions:
+
+```text
+github.token
+```
+
+Os workflows relevantes solicitam:
+
+```yaml
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+```
+
+Esse token é usado para alteração do body do PR, comentários, labels, milestones, assignees e relações de issues dentro do repositório.
+
+`PROJECT_SETUP_PAT` é uma fronteira separada e opcional destinada às operações de GitHub Projects v2. A ausência das credenciais do Project não deve impedir a sincronização normal de PR/issues.
+
+## Invariantes de segurança
+
+- A automação privilegiada executa código da base/default branch confiável.
+- O código do head do PR nunca é executado com credenciais de escrita pelo fluxo de governança.
+- PRs de forks são ignorados nas mutações privilegiadas.
+- `persist-credentials` permanece desabilitado nos checkouts confiáveis.
+- Guardrails precisa passar antes da execução normal do PR Sync.
+- PR Sync normal precisa buscar novamente o estado vivo do PR após Guardrails.
+- Exceções de PRs de promoção são avaliadas antes de qualquer mutação de task de implementação.
+- Credenciais do Project v2 permanecem separadas das mutações comuns do repositório.
+
+## Contrato de regressão
+
+Um PR de implementação recém-aberto, com branch resolvível de forma determinística, pode começar com placeholders em Linked Issue/Milestone. Sem edição manual, rerun ou segundo commit, a automação deve convergir para:
+
+```text
+Autofill no PR vivo
+ -> validar PR vivo
+ -> workflow_run com sucesso
+ -> buscar novamente o PR vivo
+ -> PR Sync
+```
+
+Se Guardrails falhar, o PR Sync normal não deve executar.
+
+Os testes de contrato em `tests/test_pr_sync.py` e `tests/test_pr_sync_autofill.py` protegem essa ordem e o comportamento de refetch do estado vivo.
diff --git a/tests/test_pr_sync.py b/tests/test_pr_sync.py
index c94db18..b1a1ba2 100644
--- a/tests/test_pr_sync.py
+++ b/tests/test_pr_sync.py
@@ -6,12 +6,13 @@
import unittest
from unittest.mock import Mock, patch
-from project_setup.github import GitHubClient
+from project_setup.github import API_BASE, GitHubClient
from project_setup.pr_sync import (
DEFAULT_SYNC_CONFIG,
PullRequestContext,
SYNC_MARKER,
apply_pr_sync,
+ context_from_event,
linked_task_number,
load_sync_config,
parent_issue_number,
@@ -221,6 +222,29 @@ def test_promotion_pull_request_skips_implementation_metadata_body(self):
self.assertEqual(validate_pull_request("develop", placeholder_body, "Q.A"), [])
self.assertEqual(validate_pull_request("Q.A", placeholder_body, "main"), [])
+ def test_workflow_run_context_refetches_live_pull_request(self):
+ client = Mock(spec=GitHubClient)
+ client.request_json.return_value = {
+ "number": 33,
+ "body": "Closes #47",
+ "base": {"ref": "develop"},
+ "head": {"ref": "issue-47-fix", "repo": {"full_name": "owner/repo"}},
+ "user": {"login": "alice"},
+ "draft": False,
+ "merged": False,
+ }
+ event = {"workflow_run": {"pull_requests": [{"number": 33}]}}
+
+ ctx = context_from_event(event, client=client, repo="owner/repo")
+
+ self.assertEqual(ctx.number, 33)
+ self.assertEqual(ctx.body, "Closes #47")
+ self.assertEqual(ctx.head_ref, "issue-47-fix")
+ client.request_json.assert_called_once_with(
+ "GET",
+ f"{API_BASE}/repos/owner/repo/pulls/33",
+ )
+
def test_missing_linked_task_sets_sticky_failure_comment(self):
client = Mock(spec=GitHubClient)
client.list_issue_comments.return_value = []
@@ -269,21 +293,22 @@ def test_success_comment_is_updated_instead_of_duplicated(self):
class PrSyncWorkflowContractTests(unittest.TestCase):
- def test_pr_sync_runs_directly_for_relevant_pr_lifecycle_events(self):
+ def test_pr_sync_waits_for_successful_guardrails_and_keeps_lifecycle_events(self):
text = (ROOT / ".github/workflows/pr-sync.yml").read_text(encoding="utf-8")
for expected in (
"name: PR Sync",
"pull_request_target:",
- "- opened",
- "- synchronize",
- "- reopened",
- "- edited",
"- ready_for_review",
"- converted_to_draft",
"- closed",
- "github.event.pull_request.head.repo.full_name == github.repository",
- "ref: ${{ github.event.pull_request.base.sha }}",
+ "workflow_run:",
+ 'workflows: ["PR metadata validation"]',
+ "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",
+ "ref: refs/heads/${{ github.event.workflow_run.pull_requests[0].base.ref }}",
"persist-credentials: false",
"PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }}",
"PROJECT_SETUP_PROJECT_NUMBER: ${{ vars.PROJECT_SETUP_PROJECT_NUMBER }}",
@@ -292,30 +317,42 @@ def test_pr_sync_runs_directly_for_relevant_pr_lifecycle_events(self):
with self.subTest(expected=expected):
self.assertIn(expected, text)
- self.assertNotIn("workflow_run:", text)
- self.assertNotIn('workflows: ["PR metadata validation", "PR guardrails"]', text)
+ for stale_direct_event in ("- opened", "- synchronize", "- reopened", "- edited"):
+ with self.subTest(stale_direct_event=stale_direct_event):
+ self.assertNotIn(stale_direct_event, text)
+
+ self.assertNotIn("python -m project_setup.pr_autofill", text)
self.assertNotIn("github.event.pull_request.head.sha", text)
self.assertNotIn("refs/heads/${{ github.event.pull_request.head.ref }}", text)
- def test_metadata_validation_depends_on_successful_pr_sync(self):
+ def test_metadata_validation_autofills_before_validating_live_pr(self):
text = (ROOT / ".github/workflows/pr-metadata.yml").read_text(encoding="utf-8")
for expected in (
"name: PR metadata validation",
- "workflow_run:",
- 'workflows: ["PR Sync"]',
- "github.event.workflow_run.conclusion == 'success'",
- "github.event.workflow_run.event == 'pull_request_target'",
- "github.event.workflow_run.pull_requests[0].number != null",
- "pull-requests: read",
- "ref: ${{ github.event.repository.default_branch }}",
- "--skip-draft-or-closed",
+ "pull_request_target:",
+ "- opened",
+ "- synchronize",
+ "- reopened",
+ "- edited",
+ "pull-requests: write",
+ "github.event.pull_request.head.repo.full_name == github.repository",
+ "ref: ${{ github.event.pull_request.base.sha }}",
+ "python -m project_setup.pr_autofill",
+ "python scripts/validation/validate_pr_body.py",
"--pr-number \"$PR_NUMBER\"",
+ "live-qa-after-guardrails",
+ "github.event.pull_request.head.ref == 'Q.A'",
+ "github.event.pull_request.base.ref == 'main'",
):
with self.subTest(expected=expected):
self.assertIn(expected, text)
- self.assertNotIn("pull_request_target:", text)
+ autofill = text.index("python -m project_setup.pr_autofill")
+ validation = text.index("python scripts/validation/validate_pr_body.py")
+ self.assertLess(autofill, validation)
+ self.assertNotIn("workflow_run:", text)
+ self.assertNotIn("--skip-draft-or-closed", text)
self.assertNotIn("PR_BODY:", text)
def test_installer_distributes_pr_sync_workflow(self):
diff --git a/tests/test_pr_sync_autofill.py b/tests/test_pr_sync_autofill.py
index 599ff81..8ccb555 100644
--- a/tests/test_pr_sync_autofill.py
+++ b/tests/test_pr_sync_autofill.py
@@ -117,14 +117,22 @@ def test_unresolvable_branch_does_not_invent_metadata(self):
class PrSyncAutofillWorkflowContractTests(unittest.TestCase):
- def test_autofill_runs_before_sync_in_same_workflow(self):
- text = (ROOT / ".github/workflows/pr-sync.yml").read_text(encoding="utf-8")
+ def test_autofill_runs_in_guardrails_before_validation_not_in_pr_sync(self):
+ guardrails = (ROOT / ".github/workflows/pr-metadata.yml").read_text(encoding="utf-8")
+ sync = (ROOT / ".github/workflows/pr-sync.yml").read_text(encoding="utf-8")
autofill = "python -m project_setup.pr_autofill"
- sync = "python -m project_setup.pr_sync"
- self.assertIn(autofill, text)
- self.assertIn(sync, text)
- self.assertLess(text.index(autofill), text.index(sync))
- self.assertIn("pull-requests: write", text)
+ validation = "python scripts/validation/validate_pr_body.py"
+
+ self.assertIn(autofill, guardrails)
+ self.assertIn(validation, guardrails)
+ self.assertLess(guardrails.index(autofill), guardrails.index(validation))
+ self.assertIn("pull-requests: write", guardrails)
+
+ self.assertNotIn(autofill, sync)
+ self.assertIn("workflow_run:", sync)
+ self.assertIn('workflows: ["PR metadata validation"]', sync)
+ self.assertIn("github.event.workflow_run.conclusion == 'success'", sync)
+ self.assertIn("python -m project_setup.pr_sync", sync)
if __name__ == "__main__":
diff --git a/tests/test_qa_workflows.py b/tests/test_qa_workflows.py
index 8d4bbf3..57f3c19 100644
--- a/tests/test_qa_workflows.py
+++ b/tests/test_qa_workflows.py
@@ -68,10 +68,11 @@ def test_live_qa_is_reusable_only_and_guardrail_gated(self):
self.assertIn("cancel-in-progress: true", live)
self.assertIn("ref: ${{ inputs.checkout_ref }}", live)
+ self.assertIn("pull_request_target:", metadata)
self.assertIn("needs: validate-pr", metadata)
self.assertIn("needs.validate-pr.result == 'success'", metadata)
- self.assertIn("pull_requests[0].head.ref == 'Q.A'", metadata)
- self.assertIn("pull_requests[0].base.ref == 'main'", metadata)
+ self.assertIn("github.event.pull_request.head.ref == 'Q.A'", metadata)
+ self.assertIn("github.event.pull_request.base.ref == 'main'", metadata)
self.assertIn("uses: ./.github/workflows/qa-live.yml", metadata)
def test_live_sandbox_prunes_only_prefixed_stale_resources(self):