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):